Skip to content

Commit b78977a

Browse files
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 <noreply@anthropic.com>
1 parent 83cf134 commit b78977a

4 files changed

Lines changed: 141 additions & 0 deletions

File tree

‎src/main/wrapper/CxConstants.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ export enum CxConstants {
2929
SCA_REMEDIATION_PACKAGE_VERSION = "--package-version",
3030
CMD_AUTH = "auth",
3131
SUB_CMD_VALIDATE = "validate",
32+
CMD_CONFIGURE = "configure",
33+
SUB_CMD_SET = "set",
34+
PROP_NAME = "--prop-name",
35+
PROP_VALUE = "--prop-value",
36+
PROP_API_KEY = "cx_apikey",
3237
CMD_PROJECT = "project",
3338
SUB_CMD_BRANCHES = "branches",
3439
CMD_SCAN = "scan",

‎src/main/wrapper/CxWrapper.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,33 @@ export class CxWrapper {
110110
return await exec.executeCommands(this.config.pathToExecutable, commands);
111111
}
112112

113+
/**
114+
* Saves an API key or OAuth refresh token as the CLI's own login (`cx configure set cx_apikey`), so
115+
* every cx process of this user, such as the cx-devassist plugin, uses the same login. cx decides
116+
* where it is stored. The value is a secret, so cx's output is never logged.
117+
*/
118+
async configureApiKey(apiKey: string): Promise<CxCommandOutput> {
119+
return this.setApiKeyProperty(apiKey);
120+
}
121+
122+
/** Removes the CLI's own login by setting cx_apikey to an empty value. */
123+
async clearApiKey(): Promise<CxCommandOutput> {
124+
return this.setApiKeyProperty("");
125+
}
126+
127+
private async setApiKeyProperty(value: string): Promise<CxCommandOutput> {
128+
const commands: string[] = [
129+
CxConstants.CMD_CONFIGURE,
130+
CxConstants.SUB_CMD_SET,
131+
CxConstants.PROP_NAME,
132+
CxConstants.PROP_API_KEY,
133+
CxConstants.PROP_VALUE,
134+
value,
135+
];
136+
const exec = new ExecutionService();
137+
return await exec.executeSensitiveCommands(this.config.pathToExecutable, commands);
138+
}
139+
113140
async scanCreate(params: ParamTypeMap): Promise<CxCommandOutput> {
114141
const commands: string[] = [CxConstants.CMD_SCAN, CxConstants.SUB_CMD_CREATE];
115142
commands.push(...this.initializeCommands(false));

‎src/main/wrapper/ExecutionService.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,39 @@ export class ExecutionService {
105105
}));
106106
}
107107

108+
/**
109+
* Runs a command whose arguments or output hold a secret, such as `configure set` with an API key
110+
* (cx prints the value it sets). Unlike executeCommands, the arguments are passed unchanged, and
111+
* cx's stdout and stderr are drained but never logged or returned: only the exit code is.
112+
*/
113+
executeSensitiveCommands(pathToExecutable: string, commands: string[]): Promise<CxCommandOutput> {
114+
return new Promise((resolve) => {
115+
let settled = false;
116+
const finish = (code: number, status: string) => {
117+
if (settled) {
118+
return;
119+
}
120+
settled = true;
121+
const cxCommandOutput = new CxCommandOutput();
122+
cxCommandOutput.exitCode = code;
123+
cxCommandOutput.status = status;
124+
resolve(cxCommandOutput);
125+
};
126+
127+
const child = spawner.spawn(pathToExecutable, commands, {windowsHide: true});
128+
child.stdout?.resume();
129+
child.stderr?.resume();
130+
child.on('error', () => {
131+
logger.error("Failed to start AST-CLI");
132+
finish(-1, "Failed to start AST-CLI");
133+
});
134+
child.on('exit', (code: number) => {
135+
logger.info("Exit code received from AST-CLI: " + code);
136+
finish(code, code === 0 ? "" : "AST-CLI exited with code " + code);
137+
});
138+
});
139+
}
140+
108141
executeKicsCommands(pathToExecutable: string, commands: string[], output?: string): [Promise<CxCommandOutput>, any] {
109142
return [new Promise((resolve, reject) => {
110143
let stderr = "";
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import {CxWrapper} from '../main/wrapper/CxWrapper';
2+
import {CxConfig} from "../main/wrapper/CxConfig";
3+
import {CxCommandOutput} from "../main/wrapper/CxCommandOutput";
4+
import {ExecutionService} from "../main/wrapper/ExecutionService";
5+
import {logger} from "../main/wrapper/loggerConfig";
6+
7+
// These tests never run the real cx: they check the arguments, and run Node itself for the output checks.
8+
describe("Configure API key", () => {
9+
const secret = "the-secret-api-key";
10+
11+
afterEach(() => {
12+
jest.restoreAllMocks();
13+
});
14+
15+
function wrapper(): CxWrapper {
16+
const config = new CxConfig();
17+
config.pathToExecutable = process.execPath;
18+
config.apiKey = "key-from-config";
19+
config.agentName = "VS Code";
20+
return new CxWrapper(config);
21+
}
22+
23+
function stubSensitiveCommands(): jest.SpyInstance {
24+
const done = new CxCommandOutput();
25+
done.exitCode = 0;
26+
return jest.spyOn(ExecutionService.prototype, "executeSensitiveCommands").mockResolvedValue(done);
27+
}
28+
29+
it('configureApiKey runs configure set for cx_apikey, with only the given value', async () => {
30+
const spy = stubSensitiveCommands();
31+
32+
const output = await wrapper().configureApiKey(secret);
33+
34+
expect(output.exitCode).toBe(0);
35+
expect(spy).toHaveBeenCalledWith(process.execPath,
36+
["configure", "set", "--prop-name", "cx_apikey", "--prop-value", secret]);
37+
});
38+
39+
it('clearApiKey sets cx_apikey to an empty value', async () => {
40+
const spy = stubSensitiveCommands();
41+
42+
await wrapper().clearApiKey();
43+
44+
expect(spy).toHaveBeenCalledWith(process.execPath,
45+
["configure", "set", "--prop-name", "cx_apikey", "--prop-value", ""]);
46+
});
47+
48+
it('executeSensitiveCommands returns the exit code and never logs or returns the output', async () => {
49+
const info = jest.spyOn(logger, "info");
50+
const error = jest.spyOn(logger, "error");
51+
const script = `process.stdout.write('${secret}'); process.stderr.write('${secret}'); process.exit(3)`;
52+
53+
const output = await new ExecutionService().executeSensitiveCommands(process.execPath, ["-e", script]);
54+
55+
expect(output.exitCode).toBe(3);
56+
expect(output.status).not.toContain(secret);
57+
expect(output.payload).toBeUndefined();
58+
const logged = [...info.mock.calls, ...error.mock.calls].map((call) => String(call[0])).join("\n");
59+
expect(logged).not.toContain(secret);
60+
});
61+
62+
it('executeSensitiveCommands passes arguments unchanged', async () => {
63+
const value = `a"b'c, d`;
64+
const script = `process.exit(process.argv[1] === ${JSON.stringify(value)} ? 0 : 1)`;
65+
66+
const output = await new ExecutionService().executeSensitiveCommands(process.execPath, ["-e", script, value]);
67+
68+
expect(output.exitCode).toBe(0);
69+
});
70+
71+
it('executeSensitiveCommands reports a failure to start', async () => {
72+
const output = await new ExecutionService().executeSensitiveCommands("does-not-exist-cx-binary", []);
73+
74+
expect(output.exitCode).toBe(-1);
75+
});
76+
});

0 commit comments

Comments
 (0)