@cantinasecurity/apex-cli 0.1.17 → 0.1.19

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/dist/config.js CHANGED
@@ -1,13 +1,92 @@
1
- import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { chmod, mkdir, readFile, rename, rm, writeFile, } from "node:fs/promises";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
5
+ import { lock } from "proper-lockfile";
4
6
  const CONFIG_DIR = path.join(os.homedir(), ".config", "apex");
5
7
  const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
6
8
  const CREDENTIALS_PATH = path.join(CONFIG_DIR, "credentials.json");
9
+ const CREDENTIAL_LIFECYCLE_LOCK_PATH = path.join(CONFIG_DIR, "credential-lifecycle");
7
10
  const DEFAULT_BASE_URL = process.env.APEX_BASE_URL ?? "https://ai.cantina.xyz/";
11
+ const GUEST_CREDENTIAL_FIELDS = [
12
+ "guest",
13
+ "guestCompanyId",
14
+ "claimToken",
15
+ "claimExpiresAt",
16
+ "guestSessionExpiresAt",
17
+ "onboardingCodeHash",
18
+ ];
19
+ let configWriteChain = Promise.resolve();
20
+ let credentialsWriteChain = Promise.resolve();
21
+ let credentialLifecycleChain = Promise.resolve();
22
+ const FILE_LOCK_STALE_MS = 30_000;
23
+ const FILE_LOCK_RETRY_OPTIONS = {
24
+ retries: 80,
25
+ factor: 1.15,
26
+ minTimeout: 10,
27
+ maxTimeout: 100,
28
+ randomize: true,
29
+ };
30
+ function canonicalizeCredentialValue(value) {
31
+ if (Array.isArray(value)) {
32
+ return value.map(canonicalizeCredentialValue);
33
+ }
34
+ if (value && typeof value === "object") {
35
+ return Object.fromEntries(Object.entries(value)
36
+ .sort(([left], [right]) => left.localeCompare(right))
37
+ .map(([key, nested]) => [key, canonicalizeCredentialValue(nested)]));
38
+ }
39
+ return value;
40
+ }
41
+ /**
42
+ * Returns an opaque, non-secret identifier for one complete credential-file
43
+ * generation. Device-login flows can carry this value across process/tool
44
+ * boundaries without exposing access, refresh, or claim credentials.
45
+ */
46
+ export function getCredentialGeneration(credentials) {
47
+ const canonical = JSON.stringify(canonicalizeCredentialValue(credentials));
48
+ const digest = createHash("sha256").update(canonical).digest("hex");
49
+ return `v1:${digest}`;
50
+ }
51
+ function serializeWrite(chain, setChain, action) {
52
+ const result = chain.then(action, action);
53
+ setChain(result.then(() => undefined, () => undefined));
54
+ return result;
55
+ }
56
+ async function withInterprocessFileLock(targetPath, action, retries = FILE_LOCK_RETRY_OPTIONS) {
57
+ await ensureConfigDir();
58
+ const release = await lock(targetPath, {
59
+ realpath: false,
60
+ stale: FILE_LOCK_STALE_MS,
61
+ update: 10_000,
62
+ retries,
63
+ });
64
+ try {
65
+ return await action();
66
+ }
67
+ finally {
68
+ await release();
69
+ }
70
+ }
71
+ function isLockContentionError(error) {
72
+ return Boolean(error &&
73
+ typeof error === "object" &&
74
+ "code" in error &&
75
+ error.code === "ELOCKED");
76
+ }
8
77
  async function ensureConfigDir() {
9
78
  await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
10
79
  }
80
+ /**
81
+ * Serializes multi-request auth lifecycle flows (redeem/claim/revoke/logout)
82
+ * across CLI and MCP processes without holding the credential-file lock during
83
+ * network I/O.
84
+ */
85
+ export function withCredentialLifecycleLock(action) {
86
+ return serializeWrite(credentialLifecycleChain, (next) => {
87
+ credentialLifecycleChain = next;
88
+ }, () => withInterprocessFileLock(CREDENTIAL_LIFECYCLE_LOCK_PATH, action));
89
+ }
11
90
  async function readJsonFile(filePath) {
12
91
  try {
13
92
  const contents = await readFile(filePath, "utf8");
@@ -19,40 +98,295 @@ async function readJsonFile(filePath) {
19
98
  }
20
99
  async function writeJsonFile(filePath, value, mode) {
21
100
  await ensureConfigDir();
22
- await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, {
23
- encoding: "utf8",
24
- mode,
25
- });
26
- await chmod(filePath, mode);
101
+ const temporaryPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`);
102
+ try {
103
+ await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, {
104
+ encoding: "utf8",
105
+ mode,
106
+ });
107
+ await chmod(temporaryPath, mode);
108
+ await rename(temporaryPath, filePath);
109
+ await chmod(filePath, mode);
110
+ }
111
+ catch (error) {
112
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
113
+ throw error;
114
+ }
27
115
  }
28
116
  export async function loadConfig() {
29
117
  const config = await readJsonFile(CONFIG_PATH);
30
- if (!config?.baseUrl) {
31
- return {
32
- version: 1,
33
- baseUrl: DEFAULT_BASE_URL,
34
- defaultCompanyId: null,
35
- telemetryDisabled: config?.telemetryDisabled ?? null,
36
- telemetryInstallId: config?.telemetryInstallId ?? null,
37
- };
38
- }
39
118
  return {
40
119
  version: 1,
41
- baseUrl: config.baseUrl,
42
- defaultCompanyId: config.defaultCompanyId ?? null,
43
- telemetryDisabled: config.telemetryDisabled ?? null,
44
- telemetryInstallId: config.telemetryInstallId ?? null,
120
+ baseUrl: config?.baseUrl || DEFAULT_BASE_URL,
121
+ defaultCompanyId: config?.defaultCompanyId ?? null,
122
+ telemetryDisabled: config?.telemetryDisabled ?? null,
123
+ telemetryInstallId: config?.telemetryInstallId ?? null,
124
+ onboardingFingerprint: config?.onboardingFingerprint ?? null,
45
125
  };
46
126
  }
47
127
  export async function saveConfig(config) {
48
- await writeJsonFile(CONFIG_PATH, config, 0o600);
128
+ await serializeWrite(configWriteChain, (next) => {
129
+ configWriteChain = next;
130
+ }, () => withInterprocessFileLock(CONFIG_PATH, () => writeJsonFile(CONFIG_PATH, config, 0o600)));
131
+ }
132
+ export async function updateConfig(updates) {
133
+ await serializeWrite(configWriteChain, (next) => {
134
+ configWriteChain = next;
135
+ }, () => withInterprocessFileLock(CONFIG_PATH, async () => {
136
+ const existing = (await readJsonFile(CONFIG_PATH)) ?? {};
137
+ await writeJsonFile(CONFIG_PATH, { ...existing, ...updates, version: 1 }, 0o600);
138
+ }));
139
+ }
140
+ /**
141
+ * Atomically returns a normalized config value or persists a freshly created
142
+ * one. The read and write share the same interprocess lock so concurrent CLI
143
+ * and MCP processes cannot each observe and use a different first-use value.
144
+ */
145
+ export async function getOrCreateConfigString(key, normalizeExisting, createValue) {
146
+ return serializeWrite(configWriteChain, (next) => {
147
+ configWriteChain = next;
148
+ }, () => withInterprocessFileLock(CONFIG_PATH, async () => {
149
+ const existing = (await readJsonFile(CONFIG_PATH)) ?? {};
150
+ const normalized = normalizeExisting(existing[key]);
151
+ if (normalized !== null) {
152
+ if (existing[key] !== normalized || existing.version !== 1) {
153
+ await writeJsonFile(CONFIG_PATH, { ...existing, [key]: normalized, version: 1 }, 0o600);
154
+ }
155
+ return normalized;
156
+ }
157
+ const created = createValue();
158
+ await writeJsonFile(CONFIG_PATH, { ...existing, [key]: created, version: 1 }, 0o600);
159
+ return created;
160
+ }));
49
161
  }
50
162
  export async function loadCredentials() {
51
163
  return readJsonFile(CREDENTIALS_PATH);
52
164
  }
53
- export async function saveCredentials(credentials) {
54
- await writeJsonFile(CREDENTIALS_PATH, credentials, 0o600);
165
+ export async function saveCredentials(credentials, options = {}) {
166
+ await serializeWrite(credentialsWriteChain, (next) => {
167
+ credentialsWriteChain = next;
168
+ }, () => withInterprocessFileLock(CREDENTIALS_PATH, async () => {
169
+ const existing = options.replace
170
+ ? null
171
+ : await readJsonFile(CREDENTIALS_PATH);
172
+ const nextCredentials = {
173
+ ...(existing ?? {}),
174
+ ...credentials,
175
+ };
176
+ if (options.clearGuestFields) {
177
+ for (const field of GUEST_CREDENTIAL_FIELDS) {
178
+ delete nextCredentials[field];
179
+ }
180
+ }
181
+ await writeJsonFile(CREDENTIALS_PATH, nextCredentials, 0o600);
182
+ }));
183
+ }
184
+ /**
185
+ * Commits device-login account credentials only while the exact credential
186
+ * file observed before device auth is still current. The matching file is
187
+ * merged so recoverable guest claim metadata survives an intentional account
188
+ * login; a mismatched winner is neither returned nor modified.
189
+ */
190
+ export async function saveDeviceLoginCredentialsIfGenerationMatches(expectedGeneration, credentials) {
191
+ try {
192
+ // Device approval may arrive while a long-running claim is waiting on the
193
+ // browser. Do not hold or queue behind that flow: the lifecycle lock makes
194
+ // this commit all-or-nothing, and immediate contention safely discards the
195
+ // approved tokens while preserving the operation already in progress.
196
+ return await withInterprocessFileLock(CREDENTIAL_LIFECYCLE_LOCK_PATH, () => serializeWrite(credentialsWriteChain, (next) => {
197
+ credentialsWriteChain = next;
198
+ }, () => withInterprocessFileLock(CREDENTIALS_PATH, async () => {
199
+ const existing = await readJsonFile(CREDENTIALS_PATH);
200
+ if (getCredentialGeneration(existing) !== expectedGeneration) {
201
+ return "generation_changed";
202
+ }
203
+ const nextCredentials = {
204
+ ...(existing ?? {}),
205
+ ...credentials,
206
+ guest: false,
207
+ };
208
+ await writeJsonFile(CREDENTIALS_PATH, nextCredentials, 0o600);
209
+ return "saved";
210
+ })), 0);
211
+ }
212
+ catch (error) {
213
+ if (isLockContentionError(error)) {
214
+ return "credential_operation_busy";
215
+ }
216
+ throw error;
217
+ }
218
+ }
219
+ /**
220
+ * Commits a rotated token pair only while the credential generation that
221
+ * initiated the refresh is still current. Callers must inspect `saved`: when
222
+ * claim/login advanced storage in the meantime, the stale response is
223
+ * discarded and the winner is returned only for diagnostics, never as retry
224
+ * credentials for the operation that lost the compare-and-swap.
225
+ */
226
+ export async function saveRefreshedCredentialsIfCurrent(expectedRefreshToken, credentials) {
227
+ return serializeWrite(credentialsWriteChain, (next) => {
228
+ credentialsWriteChain = next;
229
+ }, () => withInterprocessFileLock(CREDENTIALS_PATH, async () => {
230
+ const existing = await readJsonFile(CREDENTIALS_PATH);
231
+ if (!existing || existing.refreshToken !== expectedRefreshToken) {
232
+ return { saved: false, credentials: existing };
233
+ }
234
+ const nextCredentials = { ...existing, ...credentials };
235
+ await writeJsonFile(CREDENTIALS_PATH, nextCredentials, 0o600);
236
+ return { saved: true, credentials: nextCredentials };
237
+ }));
55
238
  }
56
- export async function clearCredentials() {
239
+ /**
240
+ * Adds an issuer origin to a legacy credential file only while the complete
241
+ * observed generation is still current. Origin migration must never overwrite
242
+ * a concurrent refresh, login, redeem, claim, or logout winner.
243
+ */
244
+ export async function bindCredentialIssuerOriginIfCurrent(expectedCredentials, issuerOrigin) {
245
+ return serializeWrite(credentialsWriteChain, (next) => {
246
+ credentialsWriteChain = next;
247
+ }, () => withInterprocessFileLock(CREDENTIALS_PATH, async () => {
248
+ const existing = await readJsonFile(CREDENTIALS_PATH);
249
+ if (getCredentialGeneration(existing) !==
250
+ getCredentialGeneration(expectedCredentials)) {
251
+ return { saved: false, credentials: existing };
252
+ }
253
+ const nextCredentials = { ...existing, issuerOrigin };
254
+ await writeJsonFile(CREDENTIALS_PATH, nextCredentials, 0o600);
255
+ return { saved: true, credentials: nextCredentials };
256
+ }));
257
+ }
258
+ function isCompatibleCredentialState(expectedCredentials, existing) {
259
+ const isAuthenticatedAccount = (credentials) => Boolean(credentials?.guest !== true &&
260
+ (credentials?.accessToken?.trim() || credentials?.refreshToken?.trim()));
261
+ const expectedRefreshToken = expectedCredentials?.refreshToken?.trim() ?? "";
262
+ const isExactGeneration = expectedCredentials === null
263
+ ? existing === null
264
+ : Boolean(expectedRefreshToken &&
265
+ existing?.refreshToken === expectedCredentials.refreshToken);
266
+ const isSameGuestRefresh = Boolean(expectedCredentials?.guest === true &&
267
+ existing?.guest === true &&
268
+ existing.guestCompanyId === expectedCredentials.guestCompanyId &&
269
+ existing.claimToken === expectedCredentials.claimToken &&
270
+ existing.claimExpiresAt === expectedCredentials.claimExpiresAt &&
271
+ existing.guestSessionExpiresAt ===
272
+ expectedCredentials.guestSessionExpiresAt &&
273
+ existing.onboardingCodeHash === expectedCredentials.onboardingCodeHash);
274
+ const expectedClaimToken = expectedCredentials?.claimToken?.trim() ?? "";
275
+ const isSameProofLifecycle = Boolean(expectedClaimToken &&
276
+ existing?.guestCompanyId === expectedCredentials?.guestCompanyId &&
277
+ existing?.claimToken === expectedCredentials?.claimToken &&
278
+ existing?.claimExpiresAt === expectedCredentials?.claimExpiresAt &&
279
+ existing?.guestSessionExpiresAt ===
280
+ expectedCredentials?.guestSessionExpiresAt &&
281
+ existing?.onboardingCodeHash === expectedCredentials?.onboardingCodeHash &&
282
+ !isAuthenticatedAccount(expectedCredentials) &&
283
+ !isAuthenticatedAccount(existing));
284
+ return isExactGeneration || isSameGuestRefresh || isSameProofLifecycle;
285
+ }
286
+ /**
287
+ * Replaces the complete credential file only when the auth state observed
288
+ * before a network flow is still compatible. A same-guest token refresh is
289
+ * compatible because it preserves the proof and guest lifecycle identity; a
290
+ * login, claim, replacement, or newer redeem is not. This prevents a slow
291
+ * redeem from overwriting a different auth flow without penalizing routine
292
+ * CLI/MCP refresh concurrency.
293
+ */
294
+ export async function replaceCredentialsIfCurrent(expectedCredentials, credentials) {
295
+ return serializeWrite(credentialsWriteChain, (next) => {
296
+ credentialsWriteChain = next;
297
+ }, () => withInterprocessFileLock(CREDENTIALS_PATH, async () => {
298
+ const existing = await readJsonFile(CREDENTIALS_PATH);
299
+ if (!isCompatibleCredentialState(expectedCredentials, existing)) {
300
+ return { saved: false, credentials: existing };
301
+ }
302
+ await writeJsonFile(CREDENTIALS_PATH, credentials, 0o600);
303
+ return { saved: true, credentials };
304
+ }));
305
+ }
306
+ /** Clears the observed guest lifecycle, including a compatible token refresh. */
307
+ export async function clearCredentialsIfCurrent(expectedCredentials, options = {}) {
308
+ return serializeWrite(credentialsWriteChain, (next) => {
309
+ credentialsWriteChain = next;
310
+ }, () => withInterprocessFileLock(CREDENTIALS_PATH, async () => {
311
+ const existing = await readJsonFile(CREDENTIALS_PATH);
312
+ if (!isCompatibleCredentialState(expectedCredentials, existing)) {
313
+ return false;
314
+ }
315
+ await clearCredentialFile(existing, options);
316
+ return true;
317
+ }));
318
+ }
319
+ /**
320
+ * Removes a revoked guest proof from a newer account session without touching
321
+ * that session's token pair. The claim-token match prevents an older revoke
322
+ * response from stripping a newer redeem generation.
323
+ */
324
+ export async function clearGuestFieldsIfClaimTokenMatches(expectedClaimToken) {
325
+ return serializeWrite(credentialsWriteChain, (next) => {
326
+ credentialsWriteChain = next;
327
+ }, () => withInterprocessFileLock(CREDENTIALS_PATH, async () => {
328
+ const existing = await readJsonFile(CREDENTIALS_PATH);
329
+ if (!existing || existing.claimToken !== expectedClaimToken) {
330
+ return false;
331
+ }
332
+ const nextCredentials = { ...existing };
333
+ for (const field of GUEST_CREDENTIAL_FIELDS) {
334
+ delete nextCredentials[field];
335
+ }
336
+ await writeJsonFile(CREDENTIALS_PATH, nextCredentials, 0o600);
337
+ return true;
338
+ }));
339
+ }
340
+ async function clearCredentialFile(existing, options) {
341
+ if (options.preserveClaimProof && existing?.claimToken) {
342
+ await writeJsonFile(CREDENTIALS_PATH, {
343
+ accessToken: "",
344
+ refreshToken: "",
345
+ accessTokenExpiresAt: "1970-01-01T00:00:00.000Z",
346
+ refreshTokenExpiresAt: "1970-01-01T00:00:00.000Z",
347
+ ...(existing.issuerOrigin
348
+ ? { issuerOrigin: existing.issuerOrigin }
349
+ : {}),
350
+ // A proof-only stub has no usable guest bearer session. Keeping
351
+ // guest=true would make local status and logout treat dead auth as
352
+ // active even though claim can use the proof independently.
353
+ guest: false,
354
+ ...(existing.guestCompanyId
355
+ ? { guestCompanyId: existing.guestCompanyId }
356
+ : {}),
357
+ claimToken: existing.claimToken,
358
+ claimExpiresAt: existing.claimExpiresAt ?? null,
359
+ guestSessionExpiresAt: existing.guestSessionExpiresAt ?? null,
360
+ ...(existing.onboardingCodeHash
361
+ ? { onboardingCodeHash: existing.onboardingCodeHash }
362
+ : {}),
363
+ }, 0o600);
364
+ return;
365
+ }
57
366
  await rm(CREDENTIALS_PATH, { force: true });
58
367
  }
368
+ export async function clearCredentials(options = {}) {
369
+ await serializeWrite(credentialsWriteChain, (next) => {
370
+ credentialsWriteChain = next;
371
+ }, () => withInterprocessFileLock(CREDENTIALS_PATH, async () => {
372
+ const existing = options.preserveClaimProof
373
+ ? await readJsonFile(CREDENTIALS_PATH)
374
+ : null;
375
+ await clearCredentialFile(existing, options);
376
+ }));
377
+ }
378
+ /** Clears only the credential generation that received the unauthorized result. */
379
+ export async function clearCredentialsIfRefreshTokenMatches(expectedRefreshToken, options = {}) {
380
+ return serializeWrite(credentialsWriteChain, (next) => {
381
+ credentialsWriteChain = next;
382
+ }, () => withInterprocessFileLock(CREDENTIALS_PATH, async () => {
383
+ const existing = await readJsonFile(CREDENTIALS_PATH);
384
+ if (!existing ||
385
+ existing.refreshToken !== expectedRefreshToken ||
386
+ (options.preserveGuestSession && existing.guest === true)) {
387
+ return false;
388
+ }
389
+ await clearCredentialFile(existing, options);
390
+ return true;
391
+ }));
392
+ }
package/dist/findings.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { fetchWorkspaceScans, getScanDisplayId, matchesScanId, } from "./scan.js";
2
- import { ApiError } from "./api-client.js";
2
+ import { ApiError, CredentialGenerationChangedError, isTerminalGuestSessionError, } from "./api-client.js";
3
3
  const FINDING_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
4
4
  const CANTINA_AUTH_TOKEN_ENV = "CANTINA_AUTH_TOKEN";
5
5
  const FINDING_WRITE_AUTH_ERROR = "Finding comments, feedback, and fix review scans require Apex authentication. Run `apex login` or use MCP `apex-auth-start` followed by `apex-auth-wait`, then retry.";
@@ -61,10 +61,27 @@ function matchesFindingRef(finding, findingRef) {
61
61
  }
62
62
  async function requestFindingAppRoute(client, path, options = {}) {
63
63
  const baseUrl = await client.getBaseUrl();
64
- const accessToken = await client.getAccessToken();
64
+ let bearerTerminalError = null;
65
+ let accessCredentials = null;
66
+ try {
67
+ accessCredentials = await client.getAccessCredentials();
68
+ }
69
+ catch (error) {
70
+ if (!isTerminalGuestSessionError(error))
71
+ throw error;
72
+ // getAccessCredentials already demoted the exact failed generation. Keep
73
+ // the typed error for claim-first guidance if cookie fallback is absent or
74
+ // also unauthorized.
75
+ bearerTerminalError = error;
76
+ }
77
+ const accessToken = accessCredentials?.accessToken ?? null;
78
+ const attemptedRefreshToken = accessCredentials?.refreshToken ?? null;
79
+ let activeBearerRefreshToken = attemptedRefreshToken;
65
80
  const legacyCookieToken = getCantinaAuthToken();
66
81
  let attemptedBearerAuth = false;
67
82
  if (!accessToken && !legacyCookieToken) {
83
+ if (bearerTerminalError)
84
+ throw bearerTerminalError;
68
85
  throw new Error(FINDING_WRITE_AUTH_ERROR);
69
86
  }
70
87
  const send = async (auth) => {
@@ -97,12 +114,27 @@ async function requestFindingAppRoute(client, path, options = {}) {
97
114
  ? { type: "bearer", token: accessToken }
98
115
  : { type: "cookie", token: legacyCookieToken };
99
116
  let { response, body } = await sendAndParse(primaryAuth);
100
- if (!response.ok && response.status === 401 && primaryAuth.type === "bearer") {
101
- const refreshedToken = await client
102
- .refreshSession()
103
- .then((credentials) => credentials?.accessToken ?? null)
104
- .catch(() => null);
117
+ if (!response.ok &&
118
+ response.status === 401 &&
119
+ primaryAuth.type === "bearer" &&
120
+ attemptedRefreshToken) {
121
+ let refreshedCredentials = null;
122
+ try {
123
+ refreshedCredentials = await client.refreshSession(attemptedRefreshToken, {
124
+ expectedAccessToken: accessToken ?? undefined,
125
+ });
126
+ }
127
+ catch (error) {
128
+ if (error instanceof CredentialGenerationChangedError)
129
+ throw error;
130
+ if (isTerminalGuestSessionError(error)) {
131
+ await client.clearCredentialsForGeneration(attemptedRefreshToken, error);
132
+ bearerTerminalError = error;
133
+ }
134
+ }
135
+ const refreshedToken = refreshedCredentials?.accessToken ?? null;
105
136
  if (refreshedToken) {
137
+ activeBearerRefreshToken = refreshedCredentials?.refreshToken ?? null;
106
138
  ({ response, body } = await sendAndParse({
107
139
  type: "bearer",
108
140
  token: refreshedToken,
@@ -117,8 +149,17 @@ async function requestFindingAppRoute(client, path, options = {}) {
117
149
  }
118
150
  if (!response.ok) {
119
151
  if (response.status === 401) {
120
- if (attemptedBearerAuth) {
121
- await client.clearCredentials();
152
+ const unauthorizedError = new ApiError(`Request failed with ${response.status}`, response.status, body);
153
+ if (isTerminalGuestSessionError(unauthorizedError)) {
154
+ if (attemptedBearerAuth && activeBearerRefreshToken) {
155
+ await client.clearCredentialsForGeneration(activeBearerRefreshToken, unauthorizedError);
156
+ }
157
+ throw unauthorizedError;
158
+ }
159
+ if (bearerTerminalError)
160
+ throw bearerTerminalError;
161
+ if (attemptedBearerAuth && activeBearerRefreshToken) {
162
+ await client.clearCredentialsForGeneration(activeBearerRefreshToken, unauthorizedError);
122
163
  }
123
164
  throw new Error(FINDING_WRITE_AUTH_ERROR);
124
165
  }
package/dist/help.js CHANGED
@@ -19,6 +19,8 @@ export const CLI_HELP_TEXT = `Usage:
19
19
  apex status Show progress for the latest or selected scan
20
20
  apex doctor Validate auth, repos, connections, and workspace binding
21
21
  apex login Sign in to Apex
22
+ apex redeem <trial-code> Start a temporary guest session without device login
23
+ apex claim Link a guest workspace and findings to a Cantina account
22
24
  apex logout Sign out locally
23
25
  apex service-key create Create an organization service key (shown once)
24
26
  apex service-key list List service-key metadata for an organization
@@ -59,7 +61,8 @@ Flags:
59
61
  --pr <number[:path,path]> Select a GitHub pull request for --mode pr
60
62
  --pr-path <path> Limit a single-PR scan to a changed file path
61
63
  --source-mode auto|remote|local Control remote-vs-local source materialization
62
- --force Start a new scan even if another scan is active
64
+ --force Start an overlapping scan, or confirm destructive guest logout
65
+ --replace Revoke and replace an existing guest trial session
63
66
  --repo <path> Include one or more explicit local source roots
64
67
  --json Print machine-readable JSON output
65
68
  --no-open Do not open browser flows automatically
@@ -74,6 +77,11 @@ Tips:
74
77
  Lite scans use --mode lite and require beta access plus provider-backed GitHub or GitLab sources.
75
78
  apex workspace use accepts a workspace name, prefix, or ID.
76
79
  Finding comments, feedback, and fix review scans use the same Apex login credentials as other CLI commands.
80
+ Trial codes go only to apex redeem. Never echo, log, save, telemeter, or send them through MCP.
81
+ Trial redemption, claim, and revocation require https://ai.cantina.xyz and reject other APEX_BASE_URL origins before sending secrets.
82
+ apex redeem never starts device login or opens a browser; apex claim is the only onboarding command that may open one.
83
+ After a guest scan, run apex claim on the same machine within the displayed claim deadline to keep the results.
84
+ Claim currently requires an account without an existing personal Apex tenant; otherwise contact Cantina support before the deadline.
77
85
  Service-key commands require an existing login; run apex login first when needed.
78
86
  Apex automatically selects your company when only one is available. With multiple companies, choose one interactively or pass --company.
79
87
  Service-key creation also requires --name and reveals the secret only once; omit --scope to grant all hosted API scopes.
@@ -110,6 +118,7 @@ Commands:
110
118
  /telemetry [status|enable|disable]
111
119
  Show or change local anonymous usage telemetry settings
112
120
  /update Update the local Apex CLI install and exit the shell
121
+ /claim Link the current guest workspace and findings to a Cantina account
113
122
  /logout Sign out locally and exit the shell
114
123
  /repos List detected repositories
115
124
  /workspace Show the current local workspace binding
@@ -124,6 +133,8 @@ Commands:
124
133
  /exit Exit Apex
125
134
 
126
135
  Scripted-only commands (run outside the interactive shell):
136
+ apex redeem <trial-code> [--json] [--replace]
137
+ Start a guest session without device login or browser access
127
138
  apex service-key create --name <name> [--company <id-or-handle>] [--scope <scope[,scope]>]
128
139
  Create an organization service key (shown once)
129
140
  apex service-key list [--company <id-or-handle>]
@@ -137,6 +148,8 @@ Tips:
137
148
  Lite scans require beta access plus provider-backed GitHub or GitLab sources.
138
149
  /workspace use accepts a workspace name, prefix, or ID.
139
150
  /findings comment, /findings feedback, and /findings fix-review use the current Apex login.
151
+ /claim is the only onboarding command that may open a browser. Trial redemption stays scripted-only and is not exposed through MCP.
152
+ Claim currently requires an account without an existing personal Apex tenant; otherwise contact Cantina support before the deadline.
140
153
  Service-key commands automatically use your company when only one is available. With multiple companies, choose one interactively or pass --company.
141
154
  Service-key secrets are shown once. Run these commands in your terminal, not in the interactive shell or through MCP.
142
155
  Invalid finding feedback requires a dismissal reason.