@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.
@@ -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: "utf8",
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
- function buildIgnoreMatcher(rootPath) {
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
- return readFile(path.join(rootPath, ".apexignore"), "utf8")
43
- .then((contents) => {
44
- matcher.add(contents);
45
- return matcher;
46
- })
47
- .catch(() => matcher);
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
- return entryPath.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, "");
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
- const output = await runGit(rootPath, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"]).catch(() => "");
66
- return output
67
- .split("\0")
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, { withFileTypes: true });
181
+ const children = await readdir(currentPath, {
182
+ withFileTypes: true,
183
+ encoding: "buffer",
184
+ });
75
185
  for (const child of children) {
76
- const childPath = path.join(currentPath, child.name);
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 rawEntries = source.kind === "git_candidate"
94
- ? await collectGitEntries(source.absolutePath)
95
- : await collectDirectoryEntries(source.absolutePath);
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((left, right) => left.localeCompare(right))) {
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((left, right) => left.localeCompare(right)),
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
- noDirRecurse: false,
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. If unauthenticated, call apex-auth-start and ask the user to approve the device login URL, then call apex-auth-wait.
16
- 3. Call apex-doctor for the target repository cwd.
17
- 4. If the directory is not bound to the right workspace, call apex-workspaces and apex-workspace-use.
18
- 5. Start and monitor scans with apex-scan, apex-status, apex-scans, apex-findings, and apex-export-findings.
19
- 6. For PR scans, call apex-scan with mode "pr" and one or more pullRequests.
20
- 7. Leave finding comments or valid/invalid feedback with apex-finding-comment and apex-finding-feedback when review collaboration is needed.
21
- 8. 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.
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
- throw new Error("Not authenticated. Call `apex-auth-start`, ask the user to approve the device login, then call `apex-auth-wait`.");
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
- }, () => "Device login started. Ask the user to open the verification URL and enter the code."));
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 expired.",
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 locally stored Apex session.",
257
- }, async () => runTool("apex-logout", async () => {
258
- const client = new ApexApiClient();
259
- await logout(client);
260
- return { ok: true };
261
- }, () => "Logged out of Apex."));
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
  };