@cantinasecurity/apex-cli 0.1.17 → 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 +30 -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 +102 -5
- 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 +253 -19
- package/dist/config.js +357 -23
- package/dist/findings.js +50 -9
- package/dist/help.js +14 -1
- package/dist/local-source-scan.js +200 -24
- package/dist/mcp.js +128 -19
- package/dist/onboarding.js +661 -0
- package/dist/redaction.js +34 -0
- package/dist/session.js +10 -1
- package/dist/shell.js +10 -3
- package/dist/telemetry.js +83 -12
- package/dist/workspace-binding.js +16 -6
- package/package.json +3 -1
- package/skills/apex-cli/SKILL.md +43 -11
|
@@ -8,6 +8,8 @@ import { promisify } from "node:util";
|
|
|
8
8
|
import ignore from "ignore";
|
|
9
9
|
import * as tar from "tar";
|
|
10
10
|
const execFileAsync = promisify(execFile);
|
|
11
|
+
const MAX_ARCHIVE_ENTRIES = 100_000;
|
|
12
|
+
const MAX_GIT_OUTPUT_BYTES = 64 * 1024 * 1024;
|
|
11
13
|
const BUILTIN_IGNORE_PATTERNS = [
|
|
12
14
|
".git",
|
|
13
15
|
".git/**",
|
|
@@ -33,21 +35,76 @@ const BUILTIN_IGNORE_PATTERNS = [
|
|
|
33
35
|
const FIXED_MTIME = new Date(0);
|
|
34
36
|
async function runGit(cwd, args) {
|
|
35
37
|
const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], {
|
|
36
|
-
encoding: "
|
|
38
|
+
encoding: "buffer",
|
|
39
|
+
// The 100k-entry archive limit can legitimately exceed Node's 1 MiB
|
|
40
|
+
// execFile default. Keep the inventory bounded without rejecting ordinary
|
|
41
|
+
// monorepos before the entry-count guard can inspect them.
|
|
42
|
+
maxBuffer: MAX_GIT_OUTPUT_BYTES,
|
|
43
|
+
env: {
|
|
44
|
+
...process.env,
|
|
45
|
+
// Git's stable English diagnostics let us distinguish a directory that
|
|
46
|
+
// is positively no longer a worktree from every other command failure.
|
|
47
|
+
LANG: "C",
|
|
48
|
+
LC_ALL: "C",
|
|
49
|
+
},
|
|
37
50
|
});
|
|
38
|
-
return stdout;
|
|
51
|
+
return Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout);
|
|
39
52
|
}
|
|
40
|
-
|
|
53
|
+
const strictUtf8Decoder = new TextDecoder("utf-8", { fatal: true });
|
|
54
|
+
function decodeUtf8Path(value) {
|
|
55
|
+
if (typeof value === "string")
|
|
56
|
+
return value;
|
|
57
|
+
try {
|
|
58
|
+
return strictUtf8Decoder.decode(value);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
throw new Error("Apex cannot archive a filename that is not valid UTF-8. Rename the entry and retry.");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function decodeNullSeparatedGitEntries(output) {
|
|
65
|
+
const entries = [];
|
|
66
|
+
let start = 0;
|
|
67
|
+
for (let index = 0; index <= output.length; index += 1) {
|
|
68
|
+
if (index < output.length && output[index] !== 0)
|
|
69
|
+
continue;
|
|
70
|
+
if (index > start) {
|
|
71
|
+
entries.push(decodeUtf8Path(output.subarray(start, index)));
|
|
72
|
+
if (entries.length > MAX_ARCHIVE_ENTRIES) {
|
|
73
|
+
throw new Error(`Apex local snapshots support at most ${MAX_ARCHIVE_ENTRIES.toLocaleString()} entries per source.`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
start = index + 1;
|
|
77
|
+
}
|
|
78
|
+
return entries;
|
|
79
|
+
}
|
|
80
|
+
async function buildIgnoreMatcher(rootPath) {
|
|
41
81
|
const matcher = ignore().add(BUILTIN_IGNORE_PATTERNS);
|
|
42
|
-
|
|
43
|
-
.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
82
|
+
try {
|
|
83
|
+
const contents = await readFile(path.join(rootPath, ".apexignore"));
|
|
84
|
+
let decoded;
|
|
85
|
+
try {
|
|
86
|
+
decoded = strictUtf8Decoder.decode(contents);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
throw new Error(".apexignore is not valid UTF-8.");
|
|
90
|
+
}
|
|
91
|
+
matcher.add(decoded);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (error.code !== "ENOENT") {
|
|
95
|
+
throw new Error("Apex could not read .apexignore. Fix or remove it before scanning.", { cause: error });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return matcher;
|
|
48
99
|
}
|
|
49
100
|
function normalizeRelativeEntry(entryPath) {
|
|
50
|
-
|
|
101
|
+
const normalizedSeparators = path.sep === "\\"
|
|
102
|
+
? entryPath.replace(/\\/g, "/")
|
|
103
|
+
: entryPath;
|
|
104
|
+
return normalizedSeparators.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
105
|
+
}
|
|
106
|
+
function compareArchivePaths(left, right) {
|
|
107
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
51
108
|
}
|
|
52
109
|
function ensureEntryWithinRoot(rootPath, relativePath, targetPath) {
|
|
53
110
|
if (path.isAbsolute(targetPath)) {
|
|
@@ -61,22 +118,83 @@ function ensureEntryWithinRoot(rootPath, relativePath, targetPath) {
|
|
|
61
118
|
throw new Error(`Refusing to archive path-escaping symlink in ${relativePath}`);
|
|
62
119
|
}
|
|
63
120
|
}
|
|
121
|
+
async function hasGitMetadataAtOrAbove(rootPath) {
|
|
122
|
+
let currentPath = path.resolve(rootPath);
|
|
123
|
+
while (true) {
|
|
124
|
+
try {
|
|
125
|
+
await lstat(path.join(currentPath, ".git"));
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
if (error.code !== "ENOENT") {
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const parentPath = path.dirname(currentPath);
|
|
134
|
+
if (parentPath === currentPath)
|
|
135
|
+
return false;
|
|
136
|
+
currentPath = parentPath;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
64
139
|
async function collectGitEntries(rootPath) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
140
|
+
// Source discovery intentionally keeps a nested worktree directory as a
|
|
141
|
+
// local archive. Detect enclosing Git metadata here so the archive still
|
|
142
|
+
// honors ignore rules inherited from the worktree root.
|
|
143
|
+
if (!(await hasGitMetadataAtOrAbove(rootPath))) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const worktreeState = decodeUtf8Path(await runGit(rootPath, ["rev-parse", "--is-inside-work-tree"])).trim();
|
|
148
|
+
if (worktreeState !== "true") {
|
|
149
|
+
throw new Error("The planned Git source is not a working tree.");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
// Once Git metadata is present, executable, permissions, ownership,
|
|
154
|
+
// corrupt metadata, and other failures must fail closed so ignored files
|
|
155
|
+
// cannot enter a raw directory fallback.
|
|
156
|
+
throw new Error("Apex could not verify the planned Git source. Fix the Git worktree and retry.", { cause: error });
|
|
157
|
+
}
|
|
158
|
+
let output;
|
|
159
|
+
try {
|
|
160
|
+
output = await runGit(rootPath, [
|
|
161
|
+
"ls-files",
|
|
162
|
+
"--cached",
|
|
163
|
+
"--others",
|
|
164
|
+
"--exclude-standard",
|
|
165
|
+
"-z",
|
|
166
|
+
]);
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
throw new Error("Apex could not inventory the Git source without risking ignored files. Fix the Git worktree and retry.", { cause: error });
|
|
170
|
+
}
|
|
171
|
+
// Decode after the command-error fallback so invalid Unix filename bytes
|
|
172
|
+
// fail explicitly instead of being replacement-decoded and silently lost.
|
|
173
|
+
return decodeNullSeparatedGitEntries(output)
|
|
68
174
|
.map((entry) => normalizeRelativeEntry(entry))
|
|
69
175
|
.filter((entry) => entry.length > 0);
|
|
70
176
|
}
|
|
71
|
-
async function collectDirectoryEntries(rootPath) {
|
|
177
|
+
async function collectDirectoryEntries(rootPath, matcher) {
|
|
72
178
|
const entries = [];
|
|
179
|
+
let visitedEntryCount = 0;
|
|
73
180
|
async function walk(currentPath) {
|
|
74
|
-
const children = await readdir(currentPath, {
|
|
181
|
+
const children = await readdir(currentPath, {
|
|
182
|
+
withFileTypes: true,
|
|
183
|
+
encoding: "buffer",
|
|
184
|
+
});
|
|
75
185
|
for (const child of children) {
|
|
76
|
-
const
|
|
186
|
+
const childName = decodeUtf8Path(child.name);
|
|
187
|
+
const childPath = path.join(currentPath, childName);
|
|
77
188
|
const relativePath = normalizeRelativeEntry(path.relative(rootPath, childPath));
|
|
78
189
|
if (!relativePath)
|
|
79
190
|
continue;
|
|
191
|
+
if (matcher.ignores(relativePath) || matcher.ignores(`${relativePath}/`)) {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
visitedEntryCount += 1;
|
|
195
|
+
if (visitedEntryCount > MAX_ARCHIVE_ENTRIES) {
|
|
196
|
+
throw new Error(`Apex local snapshots support at most ${MAX_ARCHIVE_ENTRIES.toLocaleString()} entries per source.`);
|
|
197
|
+
}
|
|
80
198
|
if (child.isDirectory()) {
|
|
81
199
|
entries.push(relativePath);
|
|
82
200
|
await walk(childPath);
|
|
@@ -90,12 +208,12 @@ async function collectDirectoryEntries(rootPath) {
|
|
|
90
208
|
}
|
|
91
209
|
async function collectArchiveEntries(source) {
|
|
92
210
|
const matcher = await buildIgnoreMatcher(source.absolutePath);
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
211
|
+
const gitEntries = await collectGitEntries(source.absolutePath);
|
|
212
|
+
const rawEntries = gitEntries ??
|
|
213
|
+
await collectDirectoryEntries(source.absolutePath, matcher);
|
|
96
214
|
const fileEntries = new Set();
|
|
97
215
|
let uncompressedBytes = 0;
|
|
98
|
-
for (const entry of rawEntries.sort(
|
|
216
|
+
for (const entry of rawEntries.sort(compareArchivePaths)) {
|
|
99
217
|
if (!entry)
|
|
100
218
|
continue;
|
|
101
219
|
if (matcher.ignores(entry) || matcher.ignores(`${entry}/`))
|
|
@@ -108,7 +226,7 @@ async function collectArchiveEntries(source) {
|
|
|
108
226
|
continue;
|
|
109
227
|
}
|
|
110
228
|
if (entryStats.isSymbolicLink()) {
|
|
111
|
-
const target = await readlink(absoluteEntryPath);
|
|
229
|
+
const target = decodeUtf8Path(await readlink(absoluteEntryPath, { encoding: "buffer" }));
|
|
112
230
|
ensureEntryWithinRoot(source.absolutePath, entry, target);
|
|
113
231
|
fileEntries.add(entry);
|
|
114
232
|
uncompressedBytes += Buffer.byteLength(target);
|
|
@@ -121,7 +239,7 @@ async function collectArchiveEntries(source) {
|
|
|
121
239
|
uncompressedBytes += entryStats.size;
|
|
122
240
|
}
|
|
123
241
|
return {
|
|
124
|
-
files: Array.from(fileEntries).sort(
|
|
242
|
+
files: Array.from(fileEntries).sort(compareArchivePaths),
|
|
125
243
|
uncompressedBytes,
|
|
126
244
|
};
|
|
127
245
|
}
|
|
@@ -138,10 +256,62 @@ async function createArchiveManifest(archivePath) {
|
|
|
138
256
|
sha256: hash.digest("hex"),
|
|
139
257
|
compressedBytes: archiveStats.size,
|
|
140
258
|
uncompressedBytes: 0,
|
|
259
|
+
entryCount: 0,
|
|
141
260
|
};
|
|
142
261
|
}
|
|
262
|
+
async function inspectCreatedArchive(archivePath, sourceRoot, expectedFiles, displayName, maxUncompressedBytes) {
|
|
263
|
+
const expectedEntries = new Set(expectedFiles);
|
|
264
|
+
const observedEntries = new Set();
|
|
265
|
+
let entryCount = 0;
|
|
266
|
+
let uncompressedBytes = 0;
|
|
267
|
+
await tar.list({
|
|
268
|
+
file: archivePath,
|
|
269
|
+
onentry(entry) {
|
|
270
|
+
const entryPath = normalizeRelativeEntry(entry.path);
|
|
271
|
+
if (!entryPath ||
|
|
272
|
+
!expectedEntries.has(entryPath) ||
|
|
273
|
+
observedEntries.has(entryPath) ||
|
|
274
|
+
!["File", "OldFile", "ContiguousFile", "SymbolicLink", "Link"].includes(entry.type)) {
|
|
275
|
+
throw new Error(`Archive for ${displayName} changed while it was being prepared.`);
|
|
276
|
+
}
|
|
277
|
+
if (entry.type === "SymbolicLink") {
|
|
278
|
+
if (!entry.linkpath) {
|
|
279
|
+
throw new Error(`Archive for ${displayName} changed while it was being prepared.`);
|
|
280
|
+
}
|
|
281
|
+
ensureEntryWithinRoot(sourceRoot, entryPath, entry.linkpath);
|
|
282
|
+
}
|
|
283
|
+
else if (entry.type === "Link") {
|
|
284
|
+
const linkedEntry = normalizeRelativeEntry(entry.linkpath ?? "");
|
|
285
|
+
if (!linkedEntry || !expectedEntries.has(linkedEntry)) {
|
|
286
|
+
throw new Error(`Archive for ${displayName} changed while it was being prepared.`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
observedEntries.add(entryPath);
|
|
290
|
+
entryCount += 1;
|
|
291
|
+
const entrySize = Number(entry.size ?? 0);
|
|
292
|
+
if (!Number.isSafeInteger(entrySize) || entrySize < 0) {
|
|
293
|
+
throw new Error(`Archive for ${displayName} has an invalid entry size.`);
|
|
294
|
+
}
|
|
295
|
+
if (entrySize > 0) {
|
|
296
|
+
uncompressedBytes += entrySize;
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
if (observedEntries.size !== expectedEntries.size ||
|
|
301
|
+
entryCount > MAX_ARCHIVE_ENTRIES ||
|
|
302
|
+
uncompressedBytes > maxUncompressedBytes) {
|
|
303
|
+
throw new Error(`Archive for ${displayName} changed while it was being prepared and now exceeds a trial upload limit.`);
|
|
304
|
+
}
|
|
305
|
+
return { entryCount, uncompressedBytes };
|
|
306
|
+
}
|
|
143
307
|
async function createDeterministicArchive(params) {
|
|
144
308
|
const { files, uncompressedBytes } = await collectArchiveEntries(params.source);
|
|
309
|
+
if (files.length > MAX_ARCHIVE_ENTRIES) {
|
|
310
|
+
throw new Error(`Apex local snapshots support at most ${MAX_ARCHIVE_ENTRIES.toLocaleString()} entries per source.`);
|
|
311
|
+
}
|
|
312
|
+
if (uncompressedBytes > params.maxUncompressedBytes) {
|
|
313
|
+
throw new Error(`Archive for ${params.source.displayName} exceeds the uncompressed upload limit.`);
|
|
314
|
+
}
|
|
145
315
|
await tar.create({
|
|
146
316
|
cwd: params.source.absolutePath,
|
|
147
317
|
file: params.archivePath,
|
|
@@ -153,7 +323,10 @@ async function createDeterministicArchive(params) {
|
|
|
153
323
|
preservePaths: false,
|
|
154
324
|
jobs: 1,
|
|
155
325
|
sync: false,
|
|
156
|
-
|
|
326
|
+
// The inventory contains files and symlinks only. If one is replaced by
|
|
327
|
+
// a directory while the archive is built, never recurse into content
|
|
328
|
+
// that was not reviewed against the ignore rules and upload limits.
|
|
329
|
+
noDirRecurse: true,
|
|
157
330
|
onWriteEntry(entry) {
|
|
158
331
|
entry.mtime = FIXED_MTIME;
|
|
159
332
|
entry.uid = 0;
|
|
@@ -162,10 +335,12 @@ async function createDeterministicArchive(params) {
|
|
|
162
335
|
entry.gname = "root";
|
|
163
336
|
},
|
|
164
337
|
}, files);
|
|
338
|
+
const observedArchive = await inspectCreatedArchive(params.archivePath, params.source.absolutePath, files, params.source.displayName, params.maxUncompressedBytes);
|
|
165
339
|
const manifest = await createArchiveManifest(params.archivePath);
|
|
166
340
|
return {
|
|
167
341
|
...manifest,
|
|
168
|
-
uncompressedBytes,
|
|
342
|
+
uncompressedBytes: observedArchive.uncompressedBytes,
|
|
343
|
+
entryCount: observedArchive.entryCount,
|
|
169
344
|
};
|
|
170
345
|
}
|
|
171
346
|
export async function fetchLocalSourceScanCapabilities(client) {
|
|
@@ -224,6 +399,7 @@ export async function prepareExplicitScanSources(args) {
|
|
|
224
399
|
const manifest = await createDeterministicArchive({
|
|
225
400
|
source: detectedSource,
|
|
226
401
|
archivePath,
|
|
402
|
+
maxUncompressedBytes: capabilities.archive.maxUncompressedBytes,
|
|
227
403
|
});
|
|
228
404
|
if (manifest.compressedBytes > capabilities.archive.maxCompressedBytes) {
|
|
229
405
|
throw new Error(`Archive for ${source.displayName} exceeds the compressed upload limit.`);
|
package/dist/mcp.js
CHANGED
|
@@ -2,23 +2,27 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
import { getMe, logout, startDeviceLogin, waitForDeviceLoginApproval } from "./auth.js";
|
|
5
|
+
import { getMe, logout, readStableDeviceLoginState, startDeviceLogin, waitForDeviceLoginApproval, } from "./auth.js";
|
|
6
6
|
import { ApexApiClient, formatApiError } from "./api-client.js";
|
|
7
|
+
import { getClaimProofState, isClaimProofAvailable, } from "./claim-proof.js";
|
|
7
8
|
import { commandCancelScan, commandConnect, commandCredits, commandDoctor, commandExportFindings, commandFindingComment, commandFindingFeedback, commandFindingFixReview, commandFindings, commandScan, commandScans, commandStatus, commandWorkspace, commandWorkspaceUse, commandWorkspaces, } from "./commands.js";
|
|
8
9
|
import { CLI_HELP_TEXT } from "./help.js";
|
|
10
|
+
import { getLocalTrialStatus, isProofOnlyOnboardingCredentials, revokeGuestOnboardingSession, } from "./onboarding.js";
|
|
9
11
|
import { createMcpServerTelemetryInvocation, createMcpTelemetryInvocation, emitInvocationCompleted, emitInvocationStarted, emitMcpServerStarted, withTelemetryContext, } from "./telemetry.js";
|
|
10
12
|
import { APEX_CLI_VERSION } from "./version.js";
|
|
11
13
|
const MCP_WORKFLOW_GUIDE = `Use Apex through these tools instead of shelling out to the CLI.
|
|
12
14
|
|
|
13
15
|
Suggested workflow:
|
|
14
16
|
1. Call apex-auth-status.
|
|
15
|
-
2.
|
|
16
|
-
3.
|
|
17
|
-
4.
|
|
18
|
-
5.
|
|
19
|
-
6.
|
|
20
|
-
7.
|
|
21
|
-
8.
|
|
17
|
+
2. Always call apex-trial-status next, even when account authentication succeeded. If claimAvailable is true, run \`apex claim\` in a terminal before starting another login or continuing with account work. If the claim deadline passed and claimAvailable is false, continue normal account authentication instead of steering to an expired claim.
|
|
18
|
+
3. If the user supplied a Cantina trial code, run \`apex redeem <code>\` in a terminal instead of starting device login. Never put the code in an MCP tool call.
|
|
19
|
+
4. Only when Apex is unauthenticated and no unexpired claim proof or trial code is available, call apex-auth-start and ask the user to approve the device login URL, then call apex-auth-wait with both the returned deviceCode and credentialGeneration.
|
|
20
|
+
5. Call apex-doctor for the target repository cwd.
|
|
21
|
+
6. If the directory is not bound to the right workspace, call apex-workspaces and apex-workspace-use.
|
|
22
|
+
7. Start and monitor scans with apex-scan, apex-status, apex-scans, apex-findings, and apex-export-findings.
|
|
23
|
+
8. For PR scans, call apex-scan with mode "pr" and one or more pullRequests.
|
|
24
|
+
9. Leave finding comments or valid/invalid feedback with apex-finding-comment and apex-finding-feedback when review collaboration is needed.
|
|
25
|
+
10. To review a fix, call apex-finding-fix-review. Pass fixPrUrls for PR-based fix reviews, or omit them to review the current repository state when Bedrock has original scan repository context.
|
|
22
26
|
|
|
23
27
|
Notes:
|
|
24
28
|
- Pass cwd explicitly for repository-specific operations.
|
|
@@ -166,7 +170,11 @@ async function runTool(toolName, action, summary, input = {}) {
|
|
|
166
170
|
async function requireAuthenticated(client) {
|
|
167
171
|
const me = await getMe(client);
|
|
168
172
|
if (!me) {
|
|
169
|
-
|
|
173
|
+
const credentials = await client.getCredentials();
|
|
174
|
+
if (isClaimProofAvailable(credentials)) {
|
|
175
|
+
throw new Error("Your guest trial session is no longer available, but its claim proof is stored on this machine. Run `apex claim` in a terminal to keep the trial results before starting another login; claim is intentionally not exposed through MCP.");
|
|
176
|
+
}
|
|
177
|
+
throw new Error("Not authenticated. If the user supplied a Cantina trial code, run `apex redeem <code>` in a terminal instead of starting device login; never put the code in an MCP tool call. Otherwise call `apex-auth-start`, ask the user to approve the device login, then call `apex-auth-wait`.");
|
|
170
178
|
}
|
|
171
179
|
return me;
|
|
172
180
|
}
|
|
@@ -210,30 +218,82 @@ function registerTools(server) {
|
|
|
210
218
|
me,
|
|
211
219
|
};
|
|
212
220
|
}, (value) => (value.authenticated ? "Apex is authenticated." : "Apex is not authenticated.")));
|
|
221
|
+
server.registerTool("apex-trial-status", {
|
|
222
|
+
title: "Apex Trial Status",
|
|
223
|
+
description: "Read locally stored guest-trial status and deadlines without returning the claim token or requiring an authenticated API request.",
|
|
224
|
+
}, async () => runTool("apex-trial-status", async () => {
|
|
225
|
+
const client = new ApexApiClient();
|
|
226
|
+
return getLocalTrialStatus(await client.getCredentials());
|
|
227
|
+
}, (value) => value.guest || value.claimAvailable
|
|
228
|
+
? "Local Apex guest-trial metadata loaded."
|
|
229
|
+
: "No local Apex guest trial is active."));
|
|
213
230
|
server.registerTool("apex-auth-start", {
|
|
214
231
|
title: "Start Apex Device Login",
|
|
215
|
-
description: "Start the Apex device login flow and return the verification URL and user code. This tool never opens a browser.",
|
|
232
|
+
description: "Start the Apex device login flow and return the verification URL and user code, unless a local trial claim proof requires `apex claim` first. This tool never opens a browser.",
|
|
216
233
|
}, async () => runTool("apex-auth-start", async () => {
|
|
217
234
|
const client = new ApexApiClient();
|
|
235
|
+
const loginState = await readStableDeviceLoginState(client);
|
|
236
|
+
const { me, credentials, credentialGeneration } = loginState;
|
|
237
|
+
if (me?.user.role?.trim().toLowerCase() === "guest") {
|
|
238
|
+
const claimState = getClaimProofState(credentials);
|
|
239
|
+
if (claimState !== "expired") {
|
|
240
|
+
return {
|
|
241
|
+
authenticated: true,
|
|
242
|
+
guestTrialActive: true,
|
|
243
|
+
claimAvailable: claimState === "available",
|
|
244
|
+
loginStarted: false,
|
|
245
|
+
warning: claimState === "available"
|
|
246
|
+
? "An active guest trial is already authenticated. Do not start device login; finish the scan and run `apex claim` in a terminal."
|
|
247
|
+
: "An active guest trial is already authenticated, but its local claim proof is missing. Do not start device login; run `apex redeem <code>` again on this machine to restore the proof before claiming.",
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (isClaimProofAvailable(credentials)) {
|
|
252
|
+
return {
|
|
253
|
+
authenticated: Boolean(me),
|
|
254
|
+
claimAvailable: true,
|
|
255
|
+
loginStarted: false,
|
|
256
|
+
warning: "A local guest-trial claim proof is available. Do not start another device login; run `apex claim` in a terminal first to keep the trial workspace, scans, and findings.",
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
if (me && me.user.role?.trim().toLowerCase() !== "guest") {
|
|
260
|
+
return {
|
|
261
|
+
authenticated: true,
|
|
262
|
+
guestTrialActive: false,
|
|
263
|
+
loginStarted: false,
|
|
264
|
+
warning: "A Cantina account is already authenticated.",
|
|
265
|
+
};
|
|
266
|
+
}
|
|
218
267
|
const login = await startDeviceLogin(client);
|
|
219
268
|
return {
|
|
220
269
|
authenticated: false,
|
|
270
|
+
credentialGeneration,
|
|
221
271
|
...login,
|
|
222
272
|
};
|
|
223
|
-
}, () =>
|
|
273
|
+
}, (value) => value.guestTrialActive
|
|
274
|
+
? String(value.warning)
|
|
275
|
+
: value.claimAvailable
|
|
276
|
+
? String(value.warning)
|
|
277
|
+
: value.authenticated
|
|
278
|
+
? String(value.warning)
|
|
279
|
+
: "Device login started. Ask the user to open the verification URL and enter the code."));
|
|
224
280
|
server.registerTool("apex-auth-wait", {
|
|
225
281
|
title: "Wait For Apex Login Approval",
|
|
226
|
-
description: "Poll a previously started Apex device login until it is approved, still pending, or
|
|
282
|
+
description: "Poll a previously started Apex device login until it is approved, still pending, expired, or superseded. Pass the opaque credential generation returned by apex-auth-start.",
|
|
227
283
|
inputSchema: {
|
|
228
284
|
deviceCode: z.string().min(1),
|
|
285
|
+
credentialGeneration: z
|
|
286
|
+
.string()
|
|
287
|
+
.regex(/^v1:[0-9a-f]{64}$/),
|
|
229
288
|
intervalSeconds: z.number().int().nonnegative().optional(),
|
|
230
289
|
expiresAt: z.string().optional(),
|
|
231
290
|
timeoutSeconds: z.number().int().positive().max(1800).optional(),
|
|
232
291
|
},
|
|
233
|
-
}, async ({ deviceCode, intervalSeconds, expiresAt, timeoutSeconds }) => runTool("apex-auth-wait", async () => {
|
|
292
|
+
}, async ({ deviceCode, credentialGeneration, intervalSeconds, expiresAt, timeoutSeconds, }) => runTool("apex-auth-wait", async () => {
|
|
234
293
|
const client = new ApexApiClient();
|
|
235
294
|
const result = await waitForDeviceLoginApproval(client, {
|
|
236
295
|
deviceCode,
|
|
296
|
+
credentialGeneration: credentialGeneration,
|
|
237
297
|
intervalSeconds,
|
|
238
298
|
expiresAt: expiresAt ?? null,
|
|
239
299
|
timeoutMs: timeoutSeconds ? timeoutSeconds * 1000 : undefined,
|
|
@@ -249,16 +309,64 @@ function registerTools(server) {
|
|
|
249
309
|
if (value.status === "expired") {
|
|
250
310
|
return "Device login expired before approval.";
|
|
251
311
|
}
|
|
312
|
+
if (value.status === "superseded") {
|
|
313
|
+
if (value.reason === "credential_operation_busy") {
|
|
314
|
+
return "Device login was approved, but another Apex credential operation was still in progress. Nothing was overwritten; check auth status after it finishes.";
|
|
315
|
+
}
|
|
316
|
+
return value.approvalReceived
|
|
317
|
+
? "Device login was approved, but the current Apex credential state was kept instead. Check auth status before retrying."
|
|
318
|
+
: "Device login was superseded by a changed Apex credential state before polling. Check auth status before retrying.";
|
|
319
|
+
}
|
|
252
320
|
return "Device login is still pending.";
|
|
253
321
|
}, { deviceCode, intervalSeconds, expiresAt, timeoutSeconds }));
|
|
254
322
|
server.registerTool("apex-logout", {
|
|
255
323
|
title: "Log Out Of Apex",
|
|
256
|
-
description: "Clear the
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
324
|
+
description: "Clear the local Apex session. Guest trials require explicit confirmation because logout revokes the trial and queues unclaimed data for deletion.",
|
|
325
|
+
inputSchema: {
|
|
326
|
+
confirmGuestRevocation: z.boolean().optional(),
|
|
327
|
+
},
|
|
328
|
+
}, async (input) => {
|
|
329
|
+
const confirmGuestRevocation = input?.confirmGuestRevocation;
|
|
330
|
+
return runTool("apex-logout", async () => {
|
|
331
|
+
const client = new ApexApiClient();
|
|
332
|
+
const credentials = await client.getCredentials();
|
|
333
|
+
const proofOnly = isProofOnlyOnboardingCredentials(credentials);
|
|
334
|
+
const hasGuestLifecycle = credentials?.guest === true || proofOnly;
|
|
335
|
+
if (hasGuestLifecycle && confirmGuestRevocation !== true) {
|
|
336
|
+
return {
|
|
337
|
+
ok: false,
|
|
338
|
+
guestTrialRevoked: false,
|
|
339
|
+
confirmationRequired: true,
|
|
340
|
+
warning: "Guest logout is destructive: it revokes the trial and queues all unclaimed source, scans, and findings for deletion. Call again with confirmGuestRevocation=true only after the user confirms.",
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
const guestTrialRevoked = hasGuestLifecycle
|
|
344
|
+
? await revokeGuestOnboardingSession(client, {
|
|
345
|
+
clearLocalOnTerminal: true,
|
|
346
|
+
expectedCredentials: credentials ?? undefined,
|
|
347
|
+
})
|
|
348
|
+
: false;
|
|
349
|
+
if (!hasGuestLifecycle) {
|
|
350
|
+
await logout(client, credentials);
|
|
351
|
+
}
|
|
352
|
+
return {
|
|
353
|
+
ok: true,
|
|
354
|
+
guestTrialRevoked,
|
|
355
|
+
...(hasGuestLifecycle && !guestTrialRevoked
|
|
356
|
+
? { serverRevocationUnavailable: true }
|
|
357
|
+
: {}),
|
|
358
|
+
proofOnly,
|
|
359
|
+
};
|
|
360
|
+
}, (value) => value.confirmationRequired
|
|
361
|
+
? String(value.warning)
|
|
362
|
+
: value.guestTrialRevoked
|
|
363
|
+
? "Guest trial revoked; unclaimed trial data is queued for deletion."
|
|
364
|
+
: value.serverRevocationUnavailable
|
|
365
|
+
? value.proofOnly
|
|
366
|
+
? "Guest trial proof cleared locally, but server revocation could not be confirmed."
|
|
367
|
+
: "Guest access cleared locally, but the terminal server session could not be revoked."
|
|
368
|
+
: "Logged out of Apex.");
|
|
369
|
+
});
|
|
262
370
|
server.registerTool("apex-doctor", {
|
|
263
371
|
title: "Run Apex Doctor",
|
|
264
372
|
description: "Validate auth, local source detection, workspace binding, and the planned remote-vs-local materialization strategy.",
|
|
@@ -686,4 +794,5 @@ export const testing = {
|
|
|
686
794
|
requireAuthenticated,
|
|
687
795
|
resolveCwd,
|
|
688
796
|
runTool,
|
|
797
|
+
workflowGuide: MCP_WORKFLOW_GUIDE,
|
|
689
798
|
};
|