From b78977a9ba5859d198f158afdc4a8003661eab0c Mon Sep 17 00:00:00 2001 From: Rahul Pidde <206018639+cx-rahul-pidde@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:36:33 +0530 Subject: [PATCH 1/3] AST-180812 - Add configureApiKey and clearApiKey for a shared CLI login Add CxWrapper.configureApiKey(key) and clearApiKey(), which run `cx configure set --prop-name cx_apikey`, so an IDE can save its sign-in as the CLI's own login. Other cx processes of the same user, such as the cx-devassist plugin, then use the same login. cx decides where to store it. Both run through the new ExecutionService.executeSensitiveCommands, which passes the arguments unchanged and never logs or returns cx's output (cx prints the value it sets), only the exit code. Co-Authored-By: Claude Opus 5.5 --- src/main/wrapper/CxConstants.ts | 5 ++ src/main/wrapper/CxWrapper.ts | 27 ++++++++++ src/main/wrapper/ExecutionService.ts | 33 ++++++++++++ src/tests/ConfigureApiKeyTest.test.ts | 76 +++++++++++++++++++++++++++ 4 files changed, 141 insertions(+) create mode 100644 src/tests/ConfigureApiKeyTest.test.ts diff --git a/src/main/wrapper/CxConstants.ts b/src/main/wrapper/CxConstants.ts index cdc8d359..6449dc0a 100644 --- a/src/main/wrapper/CxConstants.ts +++ b/src/main/wrapper/CxConstants.ts @@ -29,6 +29,11 @@ export enum CxConstants { SCA_REMEDIATION_PACKAGE_VERSION = "--package-version", CMD_AUTH = "auth", SUB_CMD_VALIDATE = "validate", + CMD_CONFIGURE = "configure", + SUB_CMD_SET = "set", + PROP_NAME = "--prop-name", + PROP_VALUE = "--prop-value", + PROP_API_KEY = "cx_apikey", CMD_PROJECT = "project", SUB_CMD_BRANCHES = "branches", CMD_SCAN = "scan", diff --git a/src/main/wrapper/CxWrapper.ts b/src/main/wrapper/CxWrapper.ts index 27250510..eb15d254 100644 --- a/src/main/wrapper/CxWrapper.ts +++ b/src/main/wrapper/CxWrapper.ts @@ -110,6 +110,33 @@ export class CxWrapper { return await exec.executeCommands(this.config.pathToExecutable, commands); } + /** + * Saves an API key or OAuth refresh token as the CLI's own login (`cx configure set cx_apikey`), so + * every cx process of this user, such as the cx-devassist plugin, uses the same login. cx decides + * where it is stored. The value is a secret, so cx's output is never logged. + */ + async configureApiKey(apiKey: string): Promise { + return this.setApiKeyProperty(apiKey); + } + + /** Removes the CLI's own login by setting cx_apikey to an empty value. */ + async clearApiKey(): Promise { + return this.setApiKeyProperty(""); + } + + private async setApiKeyProperty(value: string): Promise { + const commands: string[] = [ + CxConstants.CMD_CONFIGURE, + CxConstants.SUB_CMD_SET, + CxConstants.PROP_NAME, + CxConstants.PROP_API_KEY, + CxConstants.PROP_VALUE, + value, + ]; + const exec = new ExecutionService(); + return await exec.executeSensitiveCommands(this.config.pathToExecutable, commands); + } + async scanCreate(params: ParamTypeMap): Promise { const commands: string[] = [CxConstants.CMD_SCAN, CxConstants.SUB_CMD_CREATE]; commands.push(...this.initializeCommands(false)); diff --git a/src/main/wrapper/ExecutionService.ts b/src/main/wrapper/ExecutionService.ts index 0df01366..2d2f46e9 100644 --- a/src/main/wrapper/ExecutionService.ts +++ b/src/main/wrapper/ExecutionService.ts @@ -105,6 +105,39 @@ export class ExecutionService { })); } + /** + * Runs a command whose arguments or output hold a secret, such as `configure set` with an API key + * (cx prints the value it sets). Unlike executeCommands, the arguments are passed unchanged, and + * cx's stdout and stderr are drained but never logged or returned: only the exit code is. + */ + executeSensitiveCommands(pathToExecutable: string, commands: string[]): Promise { + return new Promise((resolve) => { + let settled = false; + const finish = (code: number, status: string) => { + if (settled) { + return; + } + settled = true; + const cxCommandOutput = new CxCommandOutput(); + cxCommandOutput.exitCode = code; + cxCommandOutput.status = status; + resolve(cxCommandOutput); + }; + + const child = spawner.spawn(pathToExecutable, commands, {windowsHide: true}); + child.stdout?.resume(); + child.stderr?.resume(); + child.on('error', () => { + logger.error("Failed to start AST-CLI"); + finish(-1, "Failed to start AST-CLI"); + }); + child.on('exit', (code: number) => { + logger.info("Exit code received from AST-CLI: " + code); + finish(code, code === 0 ? "" : "AST-CLI exited with code " + code); + }); + }); + } + executeKicsCommands(pathToExecutable: string, commands: string[], output?: string): [Promise, any] { return [new Promise((resolve, reject) => { let stderr = ""; diff --git a/src/tests/ConfigureApiKeyTest.test.ts b/src/tests/ConfigureApiKeyTest.test.ts new file mode 100644 index 00000000..dce7a34c --- /dev/null +++ b/src/tests/ConfigureApiKeyTest.test.ts @@ -0,0 +1,76 @@ +import {CxWrapper} from '../main/wrapper/CxWrapper'; +import {CxConfig} from "../main/wrapper/CxConfig"; +import {CxCommandOutput} from "../main/wrapper/CxCommandOutput"; +import {ExecutionService} from "../main/wrapper/ExecutionService"; +import {logger} from "../main/wrapper/loggerConfig"; + +// These tests never run the real cx: they check the arguments, and run Node itself for the output checks. +describe("Configure API key", () => { + const secret = "the-secret-api-key"; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function wrapper(): CxWrapper { + const config = new CxConfig(); + config.pathToExecutable = process.execPath; + config.apiKey = "key-from-config"; + config.agentName = "VS Code"; + return new CxWrapper(config); + } + + function stubSensitiveCommands(): jest.SpyInstance { + const done = new CxCommandOutput(); + done.exitCode = 0; + return jest.spyOn(ExecutionService.prototype, "executeSensitiveCommands").mockResolvedValue(done); + } + + it('configureApiKey runs configure set for cx_apikey, with only the given value', async () => { + const spy = stubSensitiveCommands(); + + const output = await wrapper().configureApiKey(secret); + + expect(output.exitCode).toBe(0); + expect(spy).toHaveBeenCalledWith(process.execPath, + ["configure", "set", "--prop-name", "cx_apikey", "--prop-value", secret]); + }); + + it('clearApiKey sets cx_apikey to an empty value', async () => { + const spy = stubSensitiveCommands(); + + await wrapper().clearApiKey(); + + expect(spy).toHaveBeenCalledWith(process.execPath, + ["configure", "set", "--prop-name", "cx_apikey", "--prop-value", ""]); + }); + + it('executeSensitiveCommands returns the exit code and never logs or returns the output', async () => { + const info = jest.spyOn(logger, "info"); + const error = jest.spyOn(logger, "error"); + const script = `process.stdout.write('${secret}'); process.stderr.write('${secret}'); process.exit(3)`; + + const output = await new ExecutionService().executeSensitiveCommands(process.execPath, ["-e", script]); + + expect(output.exitCode).toBe(3); + expect(output.status).not.toContain(secret); + expect(output.payload).toBeUndefined(); + const logged = [...info.mock.calls, ...error.mock.calls].map((call) => String(call[0])).join("\n"); + expect(logged).not.toContain(secret); + }); + + it('executeSensitiveCommands passes arguments unchanged', async () => { + const value = `a"b'c, d`; + const script = `process.exit(process.argv[1] === ${JSON.stringify(value)} ? 0 : 1)`; + + const output = await new ExecutionService().executeSensitiveCommands(process.execPath, ["-e", script, value]); + + expect(output.exitCode).toBe(0); + }); + + it('executeSensitiveCommands reports a failure to start', async () => { + const output = await new ExecutionService().executeSensitiveCommands("does-not-exist-cx-binary", []); + + expect(output.exitCode).toBe(-1); + }); +}); From a12f426c2cf87b1c1ec456e3e7267082cc5d90bc Mon Sep 17 00:00:00 2001 From: Rahul Pidde <206018639+cx-rahul-pidde@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:58:18 +0530 Subject: [PATCH 2/3] Add authLogout to run cx auth logout Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/main/wrapper/CxConstants.ts | 1 + src/main/wrapper/CxWrapper.ts | 7 +++++++ src/tests/ConfigureApiKeyTest.test.ts | 11 +++++++++++ 3 files changed, 19 insertions(+) diff --git a/src/main/wrapper/CxConstants.ts b/src/main/wrapper/CxConstants.ts index 6449dc0a..14cc2a1e 100644 --- a/src/main/wrapper/CxConstants.ts +++ b/src/main/wrapper/CxConstants.ts @@ -29,6 +29,7 @@ export enum CxConstants { SCA_REMEDIATION_PACKAGE_VERSION = "--package-version", CMD_AUTH = "auth", SUB_CMD_VALIDATE = "validate", + SUB_CMD_LOGOUT = "logout", CMD_CONFIGURE = "configure", SUB_CMD_SET = "set", PROP_NAME = "--prop-name", diff --git a/src/main/wrapper/CxWrapper.ts b/src/main/wrapper/CxWrapper.ts index eb15d254..5455db6c 100644 --- a/src/main/wrapper/CxWrapper.ts +++ b/src/main/wrapper/CxWrapper.ts @@ -110,6 +110,13 @@ export class CxWrapper { return await exec.executeCommands(this.config.pathToExecutable, commands); } + /** Signs the cx CLI out (`cx auth logout`). No credentials are sent. */ + async authLogout(): Promise { + const commands: string[] = [CxConstants.CMD_AUTH, CxConstants.SUB_CMD_LOGOUT]; + const exec = new ExecutionService(); + return await exec.executeCommands(this.config.pathToExecutable, commands); + } + /** * Saves an API key or OAuth refresh token as the CLI's own login (`cx configure set cx_apikey`), so * every cx process of this user, such as the cx-devassist plugin, uses the same login. cx decides diff --git a/src/tests/ConfigureApiKeyTest.test.ts b/src/tests/ConfigureApiKeyTest.test.ts index dce7a34c..eabe3e9a 100644 --- a/src/tests/ConfigureApiKeyTest.test.ts +++ b/src/tests/ConfigureApiKeyTest.test.ts @@ -45,6 +45,17 @@ describe("Configure API key", () => { ["configure", "set", "--prop-name", "cx_apikey", "--prop-value", ""]); }); + it('authLogout runs auth logout without credentials', async () => { + const done = new CxCommandOutput(); + done.exitCode = 0; + const spy = jest.spyOn(ExecutionService.prototype, "executeCommands").mockResolvedValue(done); + + const output = await wrapper().authLogout(); + + expect(output.exitCode).toBe(0); + expect(spy).toHaveBeenCalledWith(process.execPath, ["auth", "logout"]); + }); + it('executeSensitiveCommands returns the exit code and never logs or returns the output', async () => { const info = jest.spyOn(logger, "info"); const error = jest.spyOn(logger, "error"); From ae393c3b3c30943b2f7ee5a7e031c2485195ce6f Mon Sep 17 00:00:00 2001 From: Rahul Pidde <206018639+cx-rahul-pidde@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:05:13 +0530 Subject: [PATCH 3/3] AST-180812 - Remove clearApiKey so sign-out uses auth logout An empty configure set is not the CLI logout. authLogout already runs cx auth logout, which clears the stored login. Signed-off-by: Rahul Pidde <206018639+cx-rahul-pidde@users.noreply.github.com> --- src/main/wrapper/CxWrapper.ts | 5 ----- src/tests/ConfigureApiKeyTest.test.ts | 9 --------- 2 files changed, 14 deletions(-) diff --git a/src/main/wrapper/CxWrapper.ts b/src/main/wrapper/CxWrapper.ts index 5455db6c..5768eff5 100644 --- a/src/main/wrapper/CxWrapper.ts +++ b/src/main/wrapper/CxWrapper.ts @@ -126,11 +126,6 @@ export class CxWrapper { return this.setApiKeyProperty(apiKey); } - /** Removes the CLI's own login by setting cx_apikey to an empty value. */ - async clearApiKey(): Promise { - return this.setApiKeyProperty(""); - } - private async setApiKeyProperty(value: string): Promise { const commands: string[] = [ CxConstants.CMD_CONFIGURE, diff --git a/src/tests/ConfigureApiKeyTest.test.ts b/src/tests/ConfigureApiKeyTest.test.ts index eabe3e9a..a70488c0 100644 --- a/src/tests/ConfigureApiKeyTest.test.ts +++ b/src/tests/ConfigureApiKeyTest.test.ts @@ -36,15 +36,6 @@ describe("Configure API key", () => { ["configure", "set", "--prop-name", "cx_apikey", "--prop-value", secret]); }); - it('clearApiKey sets cx_apikey to an empty value', async () => { - const spy = stubSensitiveCommands(); - - await wrapper().clearApiKey(); - - expect(spy).toHaveBeenCalledWith(process.execPath, - ["configure", "set", "--prop-name", "cx_apikey", "--prop-value", ""]); - }); - it('authLogout runs auth logout without credentials', async () => { const done = new CxCommandOutput(); done.exitCode = 0;