@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.
- package/.claude/skills/apex-cli/SKILL.md +33 -11
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.mcp.claude.json +1 -1
- package/.mcp.codex.json +1 -1
- package/MARKETPLACE.md +1 -1
- package/README.md +109 -8
- package/dist/apex.js +15 -1
- package/dist/api-client.js +255 -35
- package/dist/args.js +8 -1
- package/dist/auth.js +171 -54
- package/dist/claim-proof.js +65 -0
- package/dist/commands.js +287 -23
- package/dist/config.js +357 -23
- package/dist/findings.js +50 -9
- package/dist/help.js +21 -5
- package/dist/local-source-scan.js +200 -24
- package/dist/mcp.js +132 -23
- package/dist/onboarding.js +661 -0
- package/dist/redaction.js +34 -0
- package/dist/session.js +10 -1
- package/dist/shell.js +13 -6
- package/dist/telemetry.js +98 -14
- package/dist/workspace-binding.js +16 -6
- package/package.json +3 -1
- package/skills/apex-cli/SKILL.md +45 -11
package/dist/config.js
CHANGED
|
@@ -1,13 +1,92 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
42
|
-
defaultCompanyId: config
|
|
43
|
-
telemetryDisabled: config
|
|
44
|
-
telemetryInstallId: config
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const CLI_HELP_TEXT = `Usage:
|
|
2
2
|
apex Open the interactive Apex shell
|
|
3
|
-
apex credits Show
|
|
3
|
+
apex credits Show standard, audit, fix review, and Lite scan entitlements for the active company
|
|
4
4
|
apex scan Create or resolve a workspace for this directory and start a scan
|
|
5
5
|
apex scans List scans for the current workspace binding
|
|
6
6
|
apex findings List findings for the latest or selected scan
|
|
@@ -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
|
|
@@ -55,11 +57,12 @@ Flags:
|
|
|
55
57
|
Choose the export format for findings
|
|
56
58
|
--output <path> Write exported findings to this file path
|
|
57
59
|
--limit <count> Limit the number of findings returned
|
|
58
|
-
--mode standard|audit|pr
|
|
60
|
+
--mode standard|audit|lite|pr Choose the scan mode
|
|
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
|
|
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
|
|
@@ -71,8 +74,14 @@ Tips:
|
|
|
71
74
|
apex scan uses the current directory as the default source root unless you pass --repo.
|
|
72
75
|
PR scans require --mode pr and at least one --pr selection for a GitHub repository.
|
|
73
76
|
audit is the current scan mode for audit scans; ultra remains accepted as a legacy alias.
|
|
77
|
+
Lite scans use --mode lite and require beta access plus provider-backed GitHub or GitLab sources.
|
|
74
78
|
apex workspace use accepts a workspace name, prefix, or ID.
|
|
75
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.
|
|
76
85
|
Service-key commands require an existing login; run apex login first when needed.
|
|
77
86
|
Apex automatically selects your company when only one is available. With multiple companies, choose one interactively or pass --company.
|
|
78
87
|
Service-key creation also requires --name and reveals the secret only once; omit --scope to grant all hosted API scopes.
|
|
@@ -87,8 +96,9 @@ Tips:
|
|
|
87
96
|
export const SHELL_HELP_TEXT = `Press Tab to autocomplete commands and common arguments.
|
|
88
97
|
|
|
89
98
|
Commands:
|
|
90
|
-
/credits Show
|
|
91
|
-
/scan [standard|audit]
|
|
99
|
+
/credits Show standard, audit, fix review, and Lite scan entitlements for the active company
|
|
100
|
+
/scan [standard|audit|lite]
|
|
101
|
+
Start a new Apex scan for this workspace
|
|
92
102
|
/scan pr <pr-number> Start a PR scan for this workspace
|
|
93
103
|
/scans List scans for this workspace
|
|
94
104
|
/findings [scan-id] List findings for the latest or selected scan
|
|
@@ -108,6 +118,7 @@ Commands:
|
|
|
108
118
|
/telemetry [status|enable|disable]
|
|
109
119
|
Show or change local anonymous usage telemetry settings
|
|
110
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
|
|
111
122
|
/logout Sign out locally and exit the shell
|
|
112
123
|
/repos List detected repositories
|
|
113
124
|
/workspace Show the current local workspace binding
|
|
@@ -122,6 +133,8 @@ Commands:
|
|
|
122
133
|
/exit Exit Apex
|
|
123
134
|
|
|
124
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
|
|
125
138
|
apex service-key create --name <name> [--company <id-or-handle>] [--scope <scope[,scope]>]
|
|
126
139
|
Create an organization service key (shown once)
|
|
127
140
|
apex service-key list [--company <id-or-handle>]
|
|
@@ -132,8 +145,11 @@ Scripted-only commands (run outside the interactive shell):
|
|
|
132
145
|
Tips:
|
|
133
146
|
PR scans require a GitHub PR number and provider-backed repository access.
|
|
134
147
|
audit is the current scan mode for audit scans; ultra remains accepted as a legacy alias.
|
|
148
|
+
Lite scans require beta access plus provider-backed GitHub or GitLab sources.
|
|
135
149
|
/workspace use accepts a workspace name, prefix, or ID.
|
|
136
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.
|
|
137
153
|
Service-key commands automatically use your company when only one is available. With multiple companies, choose one interactively or pass --company.
|
|
138
154
|
Service-key secrets are shown once. Run these commands in your terminal, not in the interactive shell or through MCP.
|
|
139
155
|
Invalid finding feedback requires a dismissal reason.
|