Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .husky/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ if [ -n "$HEAD_SHA" ] && [ "$HEAD_SHA" = "$CACHED_SHA" ]; then
echo "Tests already passed for $HEAD_SHA — skipping."
else
echo "Running full test suite (unit + integration) before push..."
npm run test:all
npm run test:all || exit 1
fi

echo "Checking for dependency vulnerabilities..."
Expand Down
33 changes: 20 additions & 13 deletions electron/ipc-setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,21 +99,28 @@ function registerSetupHandlers(ipcMain, getMainWindow) {
});

ipcMain.handle('sidecar:get-api-keys', () => {
const { readApiKeys, readApiKeyHints, saveApiKey } = require('../src/utils/api-key-store');
const { importFromAuthJson } = require('../src/utils/auth-json');
const status = readApiKeys();
const hints = readApiKeyHints();
try {
const { readApiKeys, readApiKeyHints, saveApiKey } = require('../src/utils/api-key-store');
const { importFromAuthJson } = require('../src/utils/auth-json');
const status = readApiKeys();
const hints = readApiKeyHints();

// Auto-import keys from auth.json that sidecar doesn't have yet
const { imported } = importFromAuthJson(status);
for (const entry of imported) {
saveApiKey(entry.provider, entry.key);
status[entry.provider] = true;
const visible = entry.key.slice(0, 8);
hints[entry.provider] = visible + '\u2022'.repeat(Math.max(0, Math.min(entry.key.length - 8, 12)));
}
// Auto-import keys from auth.json that sidecar doesn't have yet
const { imported } = importFromAuthJson(status);
for (const entry of imported) {
const result = saveApiKey(entry.provider, entry.key);
if (result && result.success !== false) {
status[entry.provider] = true;
const visible = entry.key.slice(0, 8);
hints[entry.provider] = visible + '\u2022'.repeat(Math.max(0, Math.min(entry.key.length - 8, 12)));
}
}

return { status, hints, imported: imported.map(e => e.provider) };
return { status, hints, imported: imported.map(e => e.provider) };
} catch (err) {
logger.error('get-api-keys handler error', { error: err.message });
return { status: {}, hints: {}, imported: [] };
}
});

ipcMain.handle('sidecar:fetch-models', async () => {
Expand Down
16 changes: 15 additions & 1 deletion src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,29 @@ function parseArgs(argv) {
const arg = argv[i];

if (arg.startsWith('--')) {
const key = arg.slice(2);
let key = arg.slice(2);
const next = argv[i + 1];

// Handle --key=value syntax
let inlineValue;
const eqIdx = key.indexOf('=');
if (eqIdx !== -1) {
inlineValue = key.slice(eqIdx + 1);
key = key.slice(0, eqIdx);
}

// Boolean flags (no value expected)
if (isBooleanFlag(key)) {
result[key] = true;
continue;
}

// If --key=value was used, use the inline value directly
if (inlineValue !== undefined) {
result[key] = parseValue(key, inlineValue);
continue;
}

// Array accumulation flags
if (key === 'exclude-mcp' && next && !next.startsWith('--')) {
result['exclude-mcp'] = result['exclude-mcp'] || [];
Expand Down
12 changes: 6 additions & 6 deletions src/sidecar/setup-window.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,13 @@ function launchSetupWindow() {
SIDECAR_MODE: 'setup'
};

const debugPort = process.env.SIDECAR_DEBUG_PORT || '9222';
logger.info('Launching setup window', { debugPort });
const debugPort = process.env.SIDECAR_DEBUG_PORT;
const args = debugPort
? [`--remote-debugging-port=${debugPort}`, mainPath]
: [mainPath];
logger.info('Launching setup window', { debugPort: debugPort || 'disabled' });

const proc = spawn(electronPath, [
`--remote-debugging-port=${debugPort}`,
mainPath
], {
const proc = spawn(electronPath, args, {
env,
stdio: ['ignore', 'pipe', 'pipe']
});
Expand Down
6 changes: 6 additions & 0 deletions src/utils/api-key-validation.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ function validateApiKey(provider, key) {
if (provider === 'anthropic') {
if (res.statusCode === 401) {
resolve({ valid: false, error: 'Invalid API key (401)' });
} else if (res.statusCode >= 500 || res.statusCode === 429) {
resolve({ valid: false, error: `Server error (${res.statusCode})` });
} else {
resolve({ valid: true });
}
Expand All @@ -75,6 +77,10 @@ function validateApiKey(provider, key) {
}
});
});
req.setTimeout(10000, () => {
req.destroy();
resolve({ valid: false, error: 'Request timed out' });
});
req.on('error', (err) => {
resolve({ valid: false, error: err.message });
});
Expand Down
54 changes: 45 additions & 9 deletions tests/api-key-store-validation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ describe('api-key-store validation', () => {
};
https.get.mockImplementation((_url, _opts, cb) => {
cb(mockResponse);
return { on: jest.fn() };
return { on: jest.fn(), setTimeout: jest.fn(), destroy: jest.fn() };
});

const result = await validateApiKey('openrouter', 'sk-or-valid');
Expand All @@ -70,7 +70,7 @@ describe('api-key-store validation', () => {
};
https.get.mockImplementation((_url, _opts, cb) => {
cb(mockResponse);
return { on: jest.fn() };
return { on: jest.fn(), setTimeout: jest.fn(), destroy: jest.fn() };
});

const result = await validateApiKey('openrouter', 'sk-or-bad');
Expand All @@ -79,7 +79,7 @@ describe('api-key-store validation', () => {

it('should resolve invalid for network error', async () => {
https.get.mockImplementation((_url, _opts, _cb) => {
const req = { on: jest.fn() };
const req = { on: jest.fn(), setTimeout: jest.fn(), destroy: jest.fn() };
setTimeout(() => {
const errCall = req.on.mock.calls.find(c => c[0] === 'error');
if (errCall) { errCall[1](new Error('Network error')); }
Expand Down Expand Up @@ -122,7 +122,7 @@ describe('api-key-store validation', () => {
};
https.get.mockImplementation((_url, _opts, cb) => {
cb(mockResponse);
return { on: jest.fn() };
return { on: jest.fn(), setTimeout: jest.fn(), destroy: jest.fn() };
});

const result = await validateApiKey('openrouter', 'sk-or-forbidden');
Expand All @@ -140,7 +140,7 @@ describe('api-key-store validation', () => {
};
https.get.mockImplementation((_url, _opts, cb) => {
cb(mockResponse);
return { on: jest.fn() };
return { on: jest.fn(), setTimeout: jest.fn(), destroy: jest.fn() };
});

const result = await validateApiKey('openrouter', 'sk-or-500');
Expand All @@ -159,7 +159,7 @@ describe('api-key-store validation', () => {
};
https.get.mockImplementation((_url, _opts, cb) => {
cb(mockResponse);
return { on: jest.fn() };
return { on: jest.fn(), setTimeout: jest.fn(), destroy: jest.fn() };
});

const result = await validateApiKey('anthropic', 'sk-ant-valid');
Expand All @@ -177,13 +177,49 @@ describe('api-key-store validation', () => {
};
https.get.mockImplementation((_url, _opts, cb) => {
cb(mockResponse);
return { on: jest.fn() };
return { on: jest.fn(), setTimeout: jest.fn(), destroy: jest.fn() };
});

const result = await validateApiKey('anthropic', 'sk-ant-bad');
expect(result).toEqual({ valid: false, error: 'Invalid API key (401)' });
});

it('should treat anthropic 429 as invalid (rate limited)', async () => {
const mockResponse = {
statusCode: 429,
on: jest.fn((event, cb) => {
if (event === 'data') { cb('Rate limited'); }
if (event === 'end') { cb(); }
return mockResponse;
})
};
https.get.mockImplementation((_url, _opts, cb) => {
cb(mockResponse);
return { on: jest.fn(), setTimeout: jest.fn(), destroy: jest.fn() };
});

const result = await validateApiKey('anthropic', 'sk-ant-ratelimited');
expect(result).toEqual({ valid: false, error: 'Server error (429)' });
});

it('should treat anthropic 500 as invalid (server error)', async () => {
const mockResponse = {
statusCode: 500,
on: jest.fn((event, cb) => {
if (event === 'data') { cb('Internal error'); }
if (event === 'end') { cb(); }
return mockResponse;
})
};
https.get.mockImplementation((_url, _opts, cb) => {
cb(mockResponse);
return { on: jest.fn(), setTimeout: jest.fn(), destroy: jest.fn() };
});

const result = await validateApiKey('anthropic', 'sk-ant-servererror');
expect(result).toEqual({ valid: false, error: 'Server error (500)' });
});

it('should validate deepseek key using correct endpoint', async () => {
let capturedUrl;
let capturedHeaders;
Expand All @@ -199,7 +235,7 @@ describe('api-key-store validation', () => {
capturedUrl = url;
capturedHeaders = opts.headers;
cb(mockResponse);
return { on: jest.fn() };
return { on: jest.fn(), setTimeout: jest.fn(), destroy: jest.fn() };
});

const result = await validateApiKey('deepseek', 'sk-deepseek-valid');
Expand All @@ -221,7 +257,7 @@ describe('api-key-store validation', () => {
https.get.mockImplementation((url, _opts, cb) => {
capturedUrl = url;
cb(mockResponse);
return { on: jest.fn() };
return { on: jest.fn(), setTimeout: jest.fn(), destroy: jest.fn() };
});

await validateApiKey('google', 'AIza-test-key');
Expand Down
22 changes: 22 additions & 0 deletions tests/cli.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,28 @@ describe('CLI Argument Parser', () => {
expect(result['no-ui']).toBe(true);
});
});

describe('--key=value syntax', () => {
it('should parse --model=gemini as model: "gemini"', () => {
const result = parseArgs(['start', '--model=gemini', '--prompt', 'test']);
expect(result.model).toBe('gemini');
});

it('should parse --timeout=30 as numeric', () => {
const result = parseArgs(['start', '--timeout=30']);
expect(result.timeout).toBe(30);
});

it('should handle values containing equals signs', () => {
const result = parseArgs(['start', '--model=google/gemini-2.5-flash']);
expect(result.model).toBe('google/gemini-2.5-flash');
});

it('should still support --model gemini (space-separated)', () => {
const result = parseArgs(['start', '--model', 'gemini']);
expect(result.model).toBe('gemini');
});
});
});

describe('validateStartArgs', () => {
Expand Down
25 changes: 23 additions & 2 deletions tests/sidecar/setup-window.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,36 @@ describe('setup-window', () => {

expect(spawn).toHaveBeenCalled();
const spawnArgs = spawn.mock.calls[0];
expect(spawnArgs[1][0]).toContain('--remote-debugging-port=');
expect(spawnArgs[1][1]).toContain('main.js');
// Debug port is opt-in: without SIDECAR_DEBUG_PORT, only main.js is passed
expect(spawnArgs[1][0]).toContain('main.js');

const env = spawnArgs[2].env;
expect(env.SIDECAR_MODE).toBe('setup');

expect(result.success).toBe(true);
});

it('should pass --remote-debugging-port when SIDECAR_DEBUG_PORT is set', async () => {
process.env.SIDECAR_DEBUG_PORT = '9333';
spawn.mockClear();
spawn.mockReturnValue(mockProcess);

const promise = launchSetupWindow();

const dataCallback = mockProcess.stdout.on.mock.calls.find(c => c[0] === 'data')[1];
dataCallback('{"status":"complete"}\n');

const closeCallback = mockProcess.on.mock.calls.find(c => c[0] === 'close')[1];
closeCallback(0);

await promise;

const spawnArgs = spawn.mock.calls[0];
expect(spawnArgs[1][0]).toBe('--remote-debugging-port=9333');
expect(spawnArgs[1][1]).toContain('main.js');
delete process.env.SIDECAR_DEBUG_PORT;
});

it('should parse enriched JSON with default model and keyCount', async () => {
const promise = launchSetupWindow();

Expand Down