Skip to content

Commit 5390ab6

Browse files
dgolovincursoragent
andcommitted
fix: macOS workaround for CRC daemon keychain access
On macOS 26+, the CRC daemon runs as a launchd agent in a background session that cannot access the user's login keychain. This causes pull secret storage and retrieval to fail with CSSMERR_CSP_NO_USER_INTERACTION / errSecInteractionNotAllowed errors. This commit works around the issue by storing the pull secret directly in the macOS login keychain from the extension process (which runs in the user's GUI session and has keychain access), then passing it to the CRC CLI via a temp file on each start: - crc-start-macos.ts: new module with macOS-specific start flow - Stores pull secret in keychain via `security -i` (stdin pipe to avoid exposing secrets in process arguments) - On start: reads from keychain -> writes temp file (0600) -> `crc start --pull-secret-file <temp>` -> deletes temp file in finally block - crc-start.ts: delegates to macStart on macOS, commander on other platforms - crc-cli.ts: fix stderr log routing — only `level=error` and `level=fatal` go to logger.error(); other logrus levels (info, warning, debug) go to logger.log() to prevent false operationFailed flags in the create resource dialog Assisted-by: <Cursor> Signed-off-by: Denis Golovin <dgolovin@redhat.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent d49cb89 commit 5390ab6

5 files changed

Lines changed: 591 additions & 5 deletions

File tree

‎src/crc-cli.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,14 @@ export function execPromise(command: string, args?: string[], options?: ExecOpti
8282

8383
process.stderr.on('data', data => {
8484
stdErr += data;
85-
options?.logger?.error(data);
85+
if (
86+
data.trim() &&
87+
(!data.startsWith('level=') || data.startsWith('level=error') || data.startsWith('level=fatal'))
88+
) {
89+
options?.logger?.error(data);
90+
} else {
91+
options?.logger?.log(data);
92+
}
8693
});
8794

8895
process.on('error', error => {

‎src/crc-start-macos.spec.ts‎

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
/**********************************************************************
2+
* Copyright (C) 2026 Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
***********************************************************************/
18+
19+
import type * as extensionApi from '@podman-desktop/api';
20+
import { beforeEach, describe, expect, test, vi } from 'vitest';
21+
22+
import * as crcCli from './crc-cli.js';
23+
import * as daemon from './daemon-commander.js';
24+
import type { Status } from './types.js';
25+
26+
// Mock node:child_process — keychainStore / keychainLoad use execFile
27+
const mockExecFile = vi.fn();
28+
vi.mock('node:child_process', () => ({
29+
execFile: (...args: unknown[]) => mockExecFile(...args),
30+
}));
31+
32+
vi.mock('node:util', () => ({
33+
promisify: (fn: unknown) =>
34+
vi.fn().mockImplementation((...args: unknown[]) => {
35+
return new Promise((resolve, reject) => {
36+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
37+
(fn as (...a: unknown[]) => void)(...args, (error: Error | null, stdout: string, stderr: string) => {
38+
if (error) reject(error);
39+
else resolve({ stdout: stdout ?? '', stderr: stderr ?? '' });
40+
});
41+
});
42+
}),
43+
}));
44+
45+
vi.mock('node:fs/promises', () => ({
46+
mkdtemp: vi.fn().mockResolvedValue('/tmp/crc-ps-test'),
47+
writeFile: vi.fn().mockResolvedValue(undefined),
48+
unlink: vi.fn().mockResolvedValue(undefined),
49+
rmdir: vi.fn().mockResolvedValue(undefined),
50+
}));
51+
52+
vi.mock('node:os', () => ({
53+
tmpdir: () => '/tmp',
54+
}));
55+
56+
vi.mock('node:path', async importOriginal => {
57+
return importOriginal();
58+
});
59+
60+
vi.mock('./crc-cli.js', () => ({
61+
execPromise: vi.fn(),
62+
getCrcCli: vi.fn().mockReturnValue('/usr/local/bin/crc'),
63+
}));
64+
65+
vi.mock('./daemon-commander.js', () => ({
66+
commander: {
67+
status: vi.fn(),
68+
},
69+
}));
70+
71+
vi.mock('./util.js', () => ({
72+
productName: 'OpenShift Local',
73+
}));
74+
75+
beforeEach(() => {
76+
vi.clearAllMocks();
77+
vi.restoreAllMocks();
78+
});
79+
80+
function mockKeychainLoadSuccess(pullSecret: string): void {
81+
// execFileAsync calls execFile(path, args, callback)
82+
mockExecFile.mockImplementation(
83+
(cmd: string, args: string[], callback?: (err: Error | null, stdout: string, stderr: string) => void) => {
84+
if (typeof callback === 'function') {
85+
// This is the promisified execFile path (keychainLoad)
86+
callback(null, pullSecret + '\n', '');
87+
}
88+
// Return a mock child process for the non-promisified path (keychainStore)
89+
return {
90+
stdin: { write: vi.fn(), end: vi.fn() },
91+
stdout: null,
92+
stderr: null,
93+
};
94+
},
95+
);
96+
}
97+
98+
function mockKeychainLoadFailure(exitCode = 44): void {
99+
mockExecFile.mockImplementation(
100+
(cmd: string, args: string[], callback?: (err: Error | null, stdout: string, stderr: string) => void) => {
101+
if (typeof callback === 'function') {
102+
const err: Error & { code?: number } = new Error('The specified item could not be found in the keychain.');
103+
err.code = exitCode;
104+
callback(err, '', '');
105+
return;
106+
}
107+
return {
108+
stdin: { write: vi.fn(), end: vi.fn() },
109+
stdout: null,
110+
stderr: null,
111+
};
112+
},
113+
);
114+
}
115+
116+
const mockLogger: extensionApi.Logger = {
117+
log: vi.fn(),
118+
warn: vi.fn(),
119+
error: vi.fn(),
120+
} as unknown as extensionApi.Logger;
121+
122+
describe('hasPullSecret', () => {
123+
test('returns true when keychain has the pull secret', async () => {
124+
mockKeychainLoadSuccess('{"auths":{}}');
125+
const { hasPullSecret } = await import('./crc-start-macos.js');
126+
expect(await hasPullSecret()).toBe(true);
127+
});
128+
129+
test('returns false when keychain item does not exist (exit 44)', async () => {
130+
mockKeychainLoadFailure(44);
131+
const { hasPullSecret } = await import('./crc-start-macos.js');
132+
expect(await hasPullSecret()).toBe(false);
133+
});
134+
135+
test('re-throws when keychain fails for a reason other than missing item', async () => {
136+
mockKeychainLoadFailure(1);
137+
const { hasPullSecret } = await import('./crc-start-macos.js');
138+
await expect(hasPullSecret()).rejects.toThrow();
139+
});
140+
});
141+
142+
describe('storePullSecret', () => {
143+
test('calls security -i with the pull secret via stdin', async () => {
144+
const stdinWrite = vi.fn();
145+
const stdinEnd = vi.fn();
146+
147+
mockExecFile.mockImplementation((cmd: string, args: string[], callback?: (err: Error | null) => void) => {
148+
if (Array.isArray(args) && args[0] === '-i') {
149+
// security -i for keychainStore
150+
if (typeof callback === 'function') {
151+
// simulate success after stdin is piped
152+
setTimeout(() => callback(null), 0);
153+
}
154+
return {
155+
stdin: { write: stdinWrite, end: stdinEnd },
156+
};
157+
}
158+
return { stdin: { write: vi.fn(), end: vi.fn() } };
159+
});
160+
161+
const { storePullSecret } = await import('./crc-start-macos.js');
162+
await storePullSecret('{"auths":{"reg.io":{"auth":"abc"}}}');
163+
164+
expect(stdinWrite).toHaveBeenCalledOnce();
165+
const writtenCommand = stdinWrite.mock.calls[0][0] as string;
166+
expect(writtenCommand).toContain('add-generic-password');
167+
expect(writtenCommand).toContain('com.redhat.crc-extension.pull-secret');
168+
expect(writtenCommand).toContain('-U');
169+
expect(stdinEnd).toHaveBeenCalledOnce();
170+
});
171+
172+
test('escapes backslashes and quotes in pull secret', async () => {
173+
const stdinWrite = vi.fn();
174+
175+
mockExecFile.mockImplementation((cmd: string, args: string[], callback?: (err: Error | null) => void) => {
176+
if (typeof callback === 'function') {
177+
setTimeout(() => callback(null), 0);
178+
}
179+
return { stdin: { write: stdinWrite, end: vi.fn() } };
180+
});
181+
182+
const { storePullSecret } = await import('./crc-start-macos.js');
183+
await storePullSecret('val\\"ue');
184+
185+
const writtenCommand = stdinWrite.mock.calls[0][0] as string;
186+
expect(writtenCommand).toContain('val\\\\\\"ue');
187+
});
188+
});
189+
190+
describe('start', () => {
191+
test('throws missing pull secret error when keychain is empty', async () => {
192+
mockKeychainLoadFailure();
193+
const { start } = await import('./crc-start-macos.js');
194+
195+
await expect(start(mockLogger)).rejects.toThrow('Failed to ask for pull secret');
196+
});
197+
198+
test('reads from keychain, writes temp file, calls crc start, and returns Running', async () => {
199+
const pullSecret = '{"auths":{"registry.redhat.io":{"auth":"dXNlcjpwYXNz"}}}';
200+
mockKeychainLoadSuccess(pullSecret);
201+
202+
const fs = await import('node:fs/promises');
203+
vi.spyOn(crcCli, 'execPromise').mockResolvedValue('');
204+
vi.spyOn(daemon.commander, 'status').mockResolvedValue({
205+
CrcStatus: 'Running',
206+
} as Status);
207+
208+
const { start } = await import('./crc-start-macos.js');
209+
const result = await start(mockLogger);
210+
211+
expect(result.Status).toBe('Running');
212+
expect(fs.writeFile).toHaveBeenCalledWith(expect.stringContaining('pull-secret.json'), pullSecret, { mode: 0o600 });
213+
expect(crcCli.execPromise).toHaveBeenCalledWith(
214+
'/usr/local/bin/crc',
215+
['start', '--pull-secret-file', expect.stringContaining('pull-secret.json')],
216+
expect.objectContaining({ logger: mockLogger }),
217+
);
218+
});
219+
220+
test('cleans up temp file even when crc start fails', async () => {
221+
mockKeychainLoadSuccess('{"auths":{}}');
222+
223+
const fs = await import('node:fs/promises');
224+
vi.spyOn(crcCli, 'execPromise').mockRejectedValue(new Error('start failed'));
225+
vi.spyOn(daemon.commander, 'status').mockRejectedValue(new Error('daemon unreachable'));
226+
227+
const { start } = await import('./crc-start-macos.js');
228+
229+
await expect(start(mockLogger)).rejects.toThrow('start failed');
230+
expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining('pull-secret.json'));
231+
expect(fs.rmdir).toHaveBeenCalledWith(expect.stringContaining('crc-ps-'));
232+
});
233+
234+
test('returns Running if daemon reports Running despite CLI error', async () => {
235+
mockKeychainLoadSuccess('{"auths":{}}');
236+
237+
vi.spyOn(crcCli, 'execPromise').mockRejectedValue(new Error('exit code 1'));
238+
vi.spyOn(daemon.commander, 'status').mockResolvedValue({
239+
CrcStatus: 'Running',
240+
} as Status);
241+
242+
const { start } = await import('./crc-start-macos.js');
243+
const result = await start(mockLogger);
244+
245+
expect(result.Status).toBe('Running');
246+
});
247+
248+
test('returns non-Running status from daemon on success', async () => {
249+
mockKeychainLoadSuccess('{"auths":{}}');
250+
251+
vi.spyOn(crcCli, 'execPromise').mockResolvedValue('');
252+
vi.spyOn(daemon.commander, 'status').mockResolvedValue({
253+
CrcStatus: 'Starting',
254+
} as Status);
255+
256+
const { start } = await import('./crc-start-macos.js');
257+
const result = await start(mockLogger);
258+
259+
expect(result.Status).toBe('Starting');
260+
});
261+
});

0 commit comments

Comments
 (0)