[cherry-pick] Revert "Share secrets between Code and Agents app via macOS Keychain" (#312735)

Co-authored-by: vs-code-engineering[bot] <vs-code-engineering[bot]@users.noreply.github.com>
This commit is contained in:
vs-code-engineering[bot]
2026-04-27 13:54:58 +00:00
committed by GitHub
parent 88406fc3c6
commit dd05fac328
18 changed files with 280 additions and 503 deletions

View File

@@ -159,14 +159,6 @@ vsda/**
@vscode/policy-watcher/index.d.ts
!@vscode/policy-watcher/build/Release/vscode-policy-watcher.node
@vscode/macos-keychain/build/**
@vscode/macos-keychain/src/**
@vscode/macos-keychain/test/**
@vscode/macos-keychain/binding.gyp
@vscode/macos-keychain/README.md
@vscode/macos-keychain/index.d.ts
!@vscode/macos-keychain/build/Release/keychainNative.node
@vscode/windows-ca-certs/**/*
!@vscode/windows-ca-certs/package.json
!@vscode/windows-ca-certs/**/*.node

View File

@@ -10,9 +10,5 @@
<true/>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>keychain-access-groups</key>
<array>
<string>$(TeamIdentifierPrefix)com.microsoft.vscode.shared-secrets</string>
</array>
</dict>
</plist>

View File

@@ -332,16 +332,6 @@ steps:
BUILD_SOURCESDIRECTORY: $(Build.SourcesDirectory)
displayName: ✍️ Codesign & Notarize
# Re-sign the app without the provisioning profile for tests.
# This strips the keychain-access-groups entitlement which requires a
# provisioning profile and is not needed for running tests. The codesign
# step reads from the archives packaged above which have the full entitlements.
- script: |
set -e
export CODESIGN_IDENTITY=$(security find-identity -v -p codesigning $(agent.tempdirectory)/buildagent.keychain | grep -oEi "([0-9A-F]{40})" | head -n 1)
DEBUG=electron-osx-sign* node build/darwin/sign.ts --skip-provisioning-profile $(agent.builddirectory)
displayName: Set Hardened Entitlements (for tests)
- ${{ if or(eq(parameters.VSCODE_RUN_ELECTRON_TESTS, true), eq(parameters.VSCODE_RUN_BROWSER_TESTS, true), eq(parameters.VSCODE_RUN_REMOTE_TESTS, true)) }}:
- template: product-build-darwin-test.yml@self
parameters:

View File

@@ -18,14 +18,7 @@ function getElectronVersion(): string {
return target;
}
const mainProvisioningProfilePath = path.join(baseDir, 'darwin', 'main.provisionprofile');
const agentsProvisioningProfilePath = path.join(baseDir, 'darwin', 'agents.provisionprofile');
function hasProvisioningProfile(): boolean {
return fs.existsSync(mainProvisioningProfilePath);
}
function getEntitlementsForFile(filePath: string, tempDir: string, useProvisioningProfile: boolean, teamId?: string): string {
function getEntitlementsForFile(filePath: string): string {
if (filePath.includes(' Helper (GPU).app')) {
return path.join(baseDir, 'azure-pipelines', 'darwin', 'helper-gpu-entitlements.plist');
} else if (filePath.includes(' Helper (Renderer).app')) {
@@ -33,51 +26,7 @@ function getEntitlementsForFile(filePath: string, tempDir: string, useProvisioni
} else if (filePath.includes(' Helper (Plugin).app')) {
return path.join(baseDir, 'azure-pipelines', 'darwin', 'helper-plugin-entitlements.plist');
}
const entitlementsPath = path.join(baseDir, 'azure-pipelines', 'darwin', 'app-entitlements.plist');
if (!useProvisioningProfile) {
// Without a provisioning profile, keychain-access-groups entitlement
// will cause signing failures. Strip it from the entitlements plist.
return getStrippedEntitlements(entitlementsPath, tempDir);
}
if (teamId) {
return getExpandedEntitlements(entitlementsPath, tempDir, teamId);
}
return entitlementsPath;
}
let _strippedEntitlementsPath: string | undefined;
/**
* Returns a path to a copy of the entitlements plist with the
* keychain-access-groups key removed.
*/
function getStrippedEntitlements(entitlementsPath: string, tempDir: string): string {
if (!_strippedEntitlementsPath) {
const content = fs.readFileSync(entitlementsPath, 'utf8');
const stripped = content.replace(
/\s*<key>keychain-access-groups<\/key>\s*<array>[\s\S]*?<\/array>/,
''
);
_strippedEntitlementsPath = path.join(tempDir, 'app-entitlements-stripped.plist');
fs.writeFileSync(_strippedEntitlementsPath, stripped);
}
return _strippedEntitlementsPath;
}
let expandedEntitlementsPath: string | undefined;
/**
* Returns a path to a copy of the entitlements plist with
* $(TeamIdentifierPrefix) expanded to the actual team identifier.
*/
function getExpandedEntitlements(entitlementsPath: string, tempDir: string, teamId: string): string {
if (!expandedEntitlementsPath) {
const content = fs.readFileSync(entitlementsPath, 'utf8');
const expanded = content.replace(/\$\(TeamIdentifierPrefix\)/g, teamId + '.');
expandedEntitlementsPath = path.join(tempDir, 'app-entitlements.plist');
fs.writeFileSync(expandedEntitlementsPath, expanded);
}
return expandedEntitlementsPath;
return path.join(baseDir, 'azure-pipelines', 'darwin', 'app-entitlements.plist');
}
async function retrySignOnKeychainError<T>(fn: () => Promise<T>, maxRetries: number = 3): Promise<T> {
@@ -109,7 +58,7 @@ async function retrySignOnKeychainError<T>(fn: () => Promise<T>, maxRetries: num
throw lastError;
}
async function main(buildDir?: string, skipProvisioningProfile?: boolean): Promise<void> {
async function main(buildDir?: string): Promise<void> {
const tempDir = process.env['AGENT_TEMPDIRECTORY'];
const arch = process.env['VSCODE_ARCH'];
const identity = process.env['CODESIGN_IDENTITY'];
@@ -129,42 +78,15 @@ async function main(buildDir?: string, skipProvisioningProfile?: boolean): Promi
? path.resolve(appRoot, appName, 'Contents', 'Applications', `${product.embedded.nameLong}.app`, 'Contents', 'Info.plist')
: undefined;
const useProvisioningProfile = !skipProvisioningProfile && hasProvisioningProfile();
const resolvedProvisioningProfile = useProvisioningProfile ? mainProvisioningProfilePath : undefined;
let teamId: string | undefined;
if (resolvedProvisioningProfile) {
const profilePlist = await spawn('security', ['cms', '-D', '-i', resolvedProvisioningProfile]);
const teamIdMatch = /<key>TeamIdentifier<\/key>\s*<array>\s*<string>(.*?)<\/string>/s.exec(profilePlist);
if (teamIdMatch) {
teamId = teamIdMatch[1];
console.log(`Extracted TeamIdentifier from provisioning profile: ${teamId}`);
} else {
console.warn('Could not extract TeamIdentifier from provisioning profile; $(TeamIdentifierPrefix) will not be expanded');
}
}
// Embed the agents provisioning profile into the embedded app bundle
// before signing, since @electron/osx-sign only supports one top-level profile.
if (useProvisioningProfile && product.embedded && fs.existsSync(agentsProvisioningProfilePath)) {
const embeddedAppPath = path.join(appRoot, appName, 'Contents', 'Applications', `${product.embedded.nameLong}.app`);
if (fs.existsSync(embeddedAppPath)) {
const embeddedProfileDest = path.join(embeddedAppPath, 'Contents', 'embedded.provisionprofile');
fs.copyFileSync(agentsProvisioningProfilePath, embeddedProfileDest);
console.log(`Embedded agents provisioning profile into ${embeddedProfileDest}`);
}
}
const appOpts: SignOptions = {
app: path.join(appRoot, appName),
platform: 'darwin',
optionsForFile: (filePath) => ({
entitlements: getEntitlementsForFile(filePath, tempDir, useProvisioningProfile, teamId),
entitlements: getEntitlementsForFile(filePath),
hardenedRuntime: true,
}),
preAutoEntitlements: false,
preEmbedProvisioningProfile: !!resolvedProvisioningProfile,
provisioningProfile: resolvedProvisioningProfile,
preEmbedProvisioningProfile: false,
keychain: path.join(tempDir, 'buildagent.keychain'),
version: getElectronVersion(),
identity,
@@ -172,8 +94,7 @@ async function main(buildDir?: string, skipProvisioningProfile?: boolean): Promi
// Only overwrite plist entries for x64 and arm64 builds,
// universal will get its copy from the x64 build.
// Skip when re-signing (skipProvisioningProfile) since entries already exist.
if (arch !== 'universal' && !skipProvisioningProfile) {
if (arch !== 'universal') {
await spawn('plutil', [
'-insert',
'NSAppleEventsUsageDescription',
@@ -250,19 +171,10 @@ async function main(buildDir?: string, skipProvisioningProfile?: boolean): Promi
}
await retrySignOnKeychainError(() => sign(appOpts));
// Dump entitlements from the signed binary for diagnostic purposes
const mainBinary = path.join(appRoot, appName, 'Contents', 'MacOS', product.nameShort);
console.log(`Dumping entitlements from signed binary: ${mainBinary}`);
const entitlementsDump = await spawn('codesign', ['--display', '--entitlements', '-', '--xml', mainBinary]);
console.log(`Signed entitlements:\n${entitlementsDump}`);
}
if (import.meta.main) {
const args = process.argv.slice(2);
const skipProvisioningProfile = args.includes('--skip-provisioning-profile');
const buildDir = args.filter(a => !a.startsWith('--'))[0];
main(buildDir, skipProvisioningProfile).catch(async err => {
main(process.argv[2]).catch(async err => {
console.error(err);
const tempDir = process.env['AGENT_TEMPDIRECTORY'];
if (tempDir) {

26
package-lock.json generated
View File

@@ -171,7 +171,6 @@
"yaserver": "^0.4.0"
},
"optionalDependencies": {
"@vscode/macos-keychain": "^0.0.1",
"windows-foreground-love": "0.6.1"
}
},
@@ -3812,31 +3811,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@vscode/macos-keychain": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@vscode/macos-keychain/-/macos-keychain-0.0.1.tgz",
"integrity": "sha512-8R5eKUZRoRUJvmoKgPrXFlEpBg6n8XKq0jyA85DLDuO1ZMbGuKsu2KsUCl7jWm06+h0ajZXUF0Z7dkk6j4IguA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"dependencies": {
"bindings": "^1.5.0",
"node-addon-api": "^8.2.0"
}
},
"node_modules/@vscode/macos-keychain/node_modules/node-addon-api": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
"integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
"license": "MIT",
"optional": true,
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/@vscode/native-watchdog": {
"version": "1.4.6",
"resolved": "https://registry.npmjs.org/@vscode/native-watchdog/-/native-watchdog-1.4.6.tgz",

View File

@@ -267,7 +267,6 @@
"url": "https://github.com/microsoft/vscode/issues"
},
"optionalDependencies": {
"@vscode/macos-keychain": "^0.0.1",
"windows-foreground-love": "0.6.1"
}
}

View File

@@ -26,7 +26,6 @@
"win32TunnelServiceMutex": "vscodeoss-tunnelservice",
"win32TunnelMutex": "vscodeoss-tunnel",
"darwinBundleIdentifier": "com.visualstudio.code.oss",
"darwinSharedKeychainServiceName": "com.visualstudio.code.oss.shared-secrets",
"darwinProfileUUID": "47827DD9-4734-49A0-AF80-7E19B11495CC",
"darwinProfilePayloadUUID": "CF808BE7-53F3-46C6-A7E2-7EDB98A5E959",
"linuxIconName": "code-oss",

View File

@@ -1,15 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// Type declarations for @vscode/macos-keychain.
// The package is an optional dependency (macOS-only native addon), so types
// are duplicated here to ensure TypeScript compilation succeeds on all platforms.
declare module '@vscode/macos-keychain' {
export function keychainSet(service: string, account: string, value: string): void;
export function keychainGet(service: string, account: string): string | undefined;
export function keychainDelete(service: string, account: string): boolean;
export function keychainList(service: string): string[];
}

View File

@@ -224,7 +224,6 @@ export interface IProductConfiguration {
readonly darwinUniversalAssetId?: string;
readonly darwinBundleIdentifier?: string;
readonly darwinSiblingBundleIdentifier?: string;
readonly darwinSharedKeychainServiceName?: string;
readonly profileTemplatesUrl?: string;
readonly commonlyUsedSettings?: string[];

View File

@@ -37,8 +37,6 @@ import { DiagnosticsMainService, IDiagnosticsMainService } from '../../platform/
import { DialogMainService, IDialogMainService } from '../../platform/dialogs/electron-main/dialogMainService.js';
import { IEncryptionMainService } from '../../platform/encryption/common/encryptionService.js';
import { EncryptionMainService } from '../../platform/encryption/electron-main/encryptionMainService.js';
import { ISharedKeychainMainService } from '../../platform/secrets/common/sharedKeychainService.js';
import { SharedKeychainMainService } from '../../platform/secrets/electron-main/sharedKeychainMainService.js';
import { ipcBrowserViewChannelName } from '../../platform/browserView/common/browserView.js';
import { ipcBrowserViewGroupChannelName } from '../../platform/browserView/common/browserViewGroup.js';
import { BrowserViewMainService, IBrowserViewMainService } from '../../platform/browserView/electron-main/browserViewMainService.js';
@@ -1094,9 +1092,6 @@ export class CodeApplication extends Disposable {
// Encryption
services.set(IEncryptionMainService, new SyncDescriptor(EncryptionMainService));
// Shared Keychain
services.set(ISharedKeychainMainService, new SyncDescriptor(SharedKeychainMainService));
// Cross-app IPC
services.set(ICrossAppIPCService, new SyncDescriptor(CrossAppIPCService));
@@ -1275,12 +1270,12 @@ export class CodeApplication extends Disposable {
this._register(new MacOSCrossAppSecretSharing(
accessor.get(IStorageMainService),
accessor.get(IEncryptionMainService),
accessor.get(ISharedKeychainMainService),
accessor.get(IStateService),
this.logService,
this.environmentMainService,
accessor.get(ILaunchMainService),
this.lifecycleMainService,
crossAppIPCService,
));
}
@@ -1297,10 +1292,6 @@ export class CodeApplication extends Disposable {
const encryptionChannel = ProxyChannel.fromService(accessor.get(IEncryptionMainService), disposables);
mainProcessElectronServer.registerChannel('encryption', encryptionChannel);
// Shared Keychain
const sharedKeychainChannel = ProxyChannel.fromService(accessor.get(ISharedKeychainMainService), disposables);
mainProcessElectronServer.registerChannel('sharedKeychain', sharedKeychainChannel);
// Browser View
const browserViewChannel = ProxyChannel.fromService(accessor.get(IBrowserViewMainService), disposables);
mainProcessElectronServer.registerChannel(ipcBrowserViewChannelName, browserViewChannel);

View File

@@ -76,6 +76,8 @@ export async function writeEncryptedSecret(
/**
* Secret keys that should be shared between the VS Code app and the agents app.
* When the agents app starts and doesn't have these secrets, it requests them
* from VS Code via crossAppIPC.
*/
export const CROSS_APP_SHARED_SECRET_KEYS: readonly string[] = [
'{"extensionId":"vscode.github-authentication","key":"github.auth"}',
@@ -135,89 +137,65 @@ export class BaseSecretStorageService extends Disposable implements ISecretStora
}
get(key: string): Promise<string | undefined> {
return this._sequencer.queue(key, () => this._doGet(key));
}
return this._sequencer.queue(key, async () => {
const storageService = await this.resolvedStorageService;
/**
* Read from the safeStorage+SQLite pipeline without going through the sequencer.
* Must only be called from within a sequencer-queued task for the same key.
*/
protected async _doGet(key: string): Promise<string | undefined> {
const storageService = await this.resolvedStorageService;
try {
return await readEncryptedSecret(
key,
(fullKey) => this.getValueFromStorage(key, fullKey, storageService),
// If the storage service is in-memory, we don't need to decrypt
this._type === 'in-memory' ? (v) => Promise.resolve(v) : (v) => this._encryptionService.decrypt(v),
this._logService,
);
} catch (e) {
this._logService.error(e);
this.delete(key);
return undefined;
}
try {
return await readEncryptedSecret(
key,
(fullKey) => this.getValueFromStorage(key, fullKey, storageService),
// If the storage service is in-memory, we don't need to decrypt
this._type === 'in-memory' ? (v) => Promise.resolve(v) : (v) => this._encryptionService.decrypt(v),
this._logService,
);
} catch (e) {
this._logService.error(e);
this.delete(key);
return undefined;
}
});
}
set(key: string, value: string): Promise<void> {
return this._sequencer.queue(key, () => this._doSet(key, value));
}
return this._sequencer.queue(key, async () => {
const storageService = await this.resolvedStorageService;
/**
* Write to the safeStorage+SQLite pipeline without going through the sequencer.
* Must only be called from within a sequencer-queued task for the same key.
*/
protected async _doSet(key: string, value: string): Promise<void> {
const storageService = await this.resolvedStorageService;
try {
await writeEncryptedSecret(
key,
value,
(fullKey, encrypted) => this.setValueInStorage(key, fullKey, encrypted, storageService),
// If the storage service is in-memory, we don't need to encrypt
this._type === 'in-memory' ? (v) => Promise.resolve(v) : (v) => this._encryptionService.encrypt(v),
this._logService,
);
} catch (e) {
this._logService.error(e);
throw e;
}
try {
await writeEncryptedSecret(
key,
value,
(fullKey, encrypted) => this.setValueInStorage(key, fullKey, encrypted, storageService),
// If the storage service is in-memory, we don't need to encrypt
this._type === 'in-memory' ? (v) => Promise.resolve(v) : (v) => this._encryptionService.encrypt(v),
this._logService,
);
} catch (e) {
this._logService.error(e);
throw e;
}
});
}
delete(key: string): Promise<void> {
return this._sequencer.queue(key, () => this._doDelete(key));
}
return this._sequencer.queue(key, async () => {
const storageService = await this.resolvedStorageService;
/**
* Delete from the safeStorage+SQLite pipeline without going through the sequencer.
* Must only be called from within a sequencer-queued task for the same key.
*/
protected async _doDelete(key: string): Promise<void> {
const storageService = await this.resolvedStorageService;
const fullKey = secretStorageKey(key);
this._logService.trace('[secrets] deleting secret for key:', fullKey);
const scope = this.useSharedStorage(key) ? StorageScope.APPLICATION_SHARED : StorageScope.APPLICATION;
storageService.remove(fullKey, scope);
this._logService.trace('[secrets] deleted secret for key:', fullKey);
const fullKey = secretStorageKey(key);
this._logService.trace('[secrets] deleting secret for key:', fullKey);
const scope = this.useSharedStorage(key) ? StorageScope.APPLICATION_SHARED : StorageScope.APPLICATION;
storageService.remove(fullKey, scope);
this._logService.trace('[secrets] deleted secret for key:', fullKey);
});
}
keys(): Promise<string[]> {
return this._sequencer.queue('__keys__', () => this._doGetKeys());
}
/**
* List all secret keys from the safeStorage+SQLite pipeline without going through the sequencer.
* Must only be called from within a sequencer-queued task.
*/
protected async _doGetKeys(): Promise<string[]> {
const storageService = await this.resolvedStorageService;
this._logService.trace('[secrets] fetching keys of all secrets');
const allKeys = storageService.keys(StorageScope.APPLICATION, StorageTarget.MACHINE);
this._logService.trace('[secrets] fetched keys of all secrets');
return allKeys.filter(key => key.startsWith(SECRET_STORAGE_PREFIX)).map(key => key.slice(SECRET_STORAGE_PREFIX.length));
return this._sequencer.queue('__keys__', async () => {
const storageService = await this.resolvedStorageService;
this._logService.trace('[secrets] fetching keys of all secrets');
const allKeys = storageService.keys(StorageScope.APPLICATION, StorageTarget.MACHINE);
this._logService.trace('[secrets] fetched keys of all secrets');
return allKeys.filter(key => key.startsWith(SECRET_STORAGE_PREFIX)).map(key => key.slice(SECRET_STORAGE_PREFIX.length));
});
}
private getValueFromStorage(key: string, fullKey: string, storageService: IStorageService): string | undefined {

View File

@@ -1,25 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { createDecorator } from '../../instantiation/common/instantiation.js';
/**
* Provides shared keychain access between Code and the embedded Agents app
* via a macOS keychain access group. On non-macOS platforms the implementation
* is a no-op (returns undefined/empty for all operations).
*/
export const ISharedKeychainService = createDecorator<ISharedKeychainService>('sharedKeychainService');
export interface ISharedKeychainService {
readonly _serviceBrand: undefined;
get(key: string): Promise<string | undefined>;
set(key: string, value: string): Promise<void>;
delete(key: string): Promise<boolean>;
keys(): Promise<string[]>;
}
export const ISharedKeychainMainService = createDecorator<ISharedKeychainMainService>('sharedKeychainMainService');
export interface ISharedKeychainMainService extends ISharedKeychainService { }

View File

@@ -5,49 +5,75 @@
import { execFile } from 'child_process';
import { dirname } from '../../../base/common/path.js';
import { Disposable } from '../../../base/common/lifecycle.js';
import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js';
import { ILogService } from '../../log/common/log.js';
import { IEncryptionMainService } from '../../encryption/common/encryptionService.js';
import { IStorageMainService } from '../../storage/electron-main/storageMainService.js';
import { CROSS_APP_SHARED_SECRET_KEYS, readEncryptedSecret } from '../common/secrets.js';
import { CROSS_APP_SHARED_SECRET_KEYS, secretStorageKey, readEncryptedSecret, writeEncryptedSecret } from '../common/secrets.js';
import { IStateService } from '../../state/node/state.js';
import { INodeProcess, isMacintosh } from '../../../base/common/platform.js';
import { IStorageMain } from '../../storage/electron-main/storageMain.js';
import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js';
import { ILaunchMainService } from '../../launch/electron-main/launchMainService.js';
import { ILifecycleMainService } from '../../lifecycle/electron-main/lifecycleMainService.js';
import { ISharedKeychainMainService } from '../common/sharedKeychainService.js';
import { ICrossAppIPCService } from '../../crossAppIpc/electron-main/crossAppIpcService.js';
const MIGRATION_STATE_KEY = 'sharedKeychain.migrationDone';
const HOST_SPAWN_STATE_KEY = 'sharedKeychain.hostSpawnDone';
const MIGRATION_STATE_KEY = 'crossAppSecretSharing.migrationDone';
/**
* Message types exchanged between apps over crossAppIPC for secret sharing.
*/
const enum CrossAppSecretMessageType {
/** Agents → Host: Request secrets */
SecretRequest = 'secrets/request',
/** Host → Agents: Response with secrets */
SecretResponse = 'secrets/response',
/** Agents → Host: Confirms secrets were stored, both sides mark migration done */
SecretAck = 'secrets/ack',
}
interface CrossAppSecretMessage {
type: CrossAppSecretMessageType;
data?: Record<string, string>;
}
/**
* Coordinates one-time secret migration between the VS Code app and the
* agents app via the macOS shared keychain (macOS only).
* agents app using Electron's crossAppIPC (macOS only).
*
* Each app migrates its own secrets from safeStorage+SQLite into the
* shared keychain on startup. The agents app also spawns Code.app
* (once) with `--share-secrets-with-agents-app` to trigger Code's
* migration if the shared keychain doesn't yet contain all expected
* keys.
* **Demand-driven**: Only the agents app initiates migration. If it
* detects that migration hasn't been done yet, it:
* 1. Waits for the crossAppIPC connection (managed by ICrossAppIPCService).
* 2. Spawns Code.app with `--share-secrets-with-agents-app`, which
* either starts Code.app fresh or (if already running) forwards
* the arg to the existing instance via the node IPC socket.
* 3. Code.app creates its own crossAppIPC connection when it sees
* the arg, and the two connect.
* 4. Agents app sends `SecretRequest` → Code.app responds with
* `SecretResponse` → Agents app sends `SecretAck`.
* 5. Both sides mark migration as done. Code.app quits if it was
* launched solely for this purpose.
*
* After migration, both apps read from and write to the shared keychain
* for cross-app secret keys (via {@link NativeSecretStorageService}).
* Security: crossAppIPC uses code-signature verification (Mach ports
* on macOS) — the kernel authenticates both endpoints. No secrets are
* ever in process args, files, or network.
*/
export class MacOSCrossAppSecretSharing extends Disposable {
private readonly isEmbeddedApp: boolean;
private readonly applicationStorage: IStorageMain;
private _onHostMigrationComplete: (() => void) | undefined;
private readonly hostHandshakeListeners = this._register(new DisposableStore());
constructor(
storageMainService: IStorageMainService,
private readonly encryptionMainService: IEncryptionMainService,
private readonly sharedKeychainMainService: ISharedKeychainMainService,
private readonly stateService: IStateService,
private readonly logService: ILogService,
environmentMainService: IEnvironmentMainService,
launchMainService: ILaunchMainService,
lifecycleMainService: ILifecycleMainService,
private readonly crossAppIPCService: ICrossAppIPCService,
) {
super();
this.isEmbeddedApp = !!(process as INodeProcess).isEmbeddedApp;
@@ -61,96 +87,143 @@ export class MacOSCrossAppSecretSharing extends Disposable {
lifecycleMainService: ILifecycleMainService,
): void {
if (this.isEmbeddedApp) {
// Agents app: migrate own secrets + spawn Code.app if needed
// Agents app: initiate migration if needed
this.initializeAsAgentsApp();
} else if (environmentMainService.args['share-secrets-with-agents-app']) {
// Code.app launched with --share-secrets-with-agents-app:
// migrate secrets to shared keychain, then quit if no other reason to stay
// Code.app launched fresh with --share-secrets-with-agents-app:
// respond to the agents app's request, then quit if no other reason to stay
const hasOtherArgs = environmentMainService.args._.length > 0 || environmentMainService.args['folder-uri'] || environmentMainService.args['file-uri'];
this.migrateSecrets().then(() => {
if (!hasOtherArgs) {
this.logService.info('[CrossAppSecretSharing] Host app was launched for migration only, quitting');
lifecycleMainService.quit();
}
this.initializeAsHostApp(hasOtherArgs ? undefined : () => {
this.logService.info('[CrossAppSecretSharing] Host app was launched for migration only, quitting');
lifecycleMainService.quit();
});
} else {
// Code.app normal startup: migrate own secrets
this.migrateSecrets();
// Also respond to spawn requests from the agents app
// Code.app already running: listen for --share-secrets-with-agents-app
// forwarded from a second instance via the launch service
this._register(launchMainService.onDidRequestShareSecrets(() => {
this.migrateSecrets();
this.initializeAsHostApp();
}));
}
}
private async initializeAsAgentsApp(): Promise<void> {
if (!isMacintosh) {
if (!isMacintosh || !this.isEmbeddedApp) {
return;
}
// Migrate own secrets (if any) to shared keychain
await this.migrateSecrets();
// If we've already spawned Code.app before, don't do it again
if (this.stateService.getItem<boolean>(HOST_SPAWN_STATE_KEY, false)) {
return;
}
// Check if the shared keychain has all expected keys
let needsHostMigration = false;
for (const key of CROSS_APP_SHARED_SECRET_KEYS) {
if (await this.sharedKeychainMainService.get(key) === undefined) {
needsHostMigration = true;
break;
}
}
if (needsHostMigration) {
this.logService.info('[CrossAppSecretSharing] Shared keychain incomplete, spawning host app');
this.spawnHostApp();
}
// Mark that we've attempted the host spawn (don't retry on next startup)
this.stateService.setItem(HOST_SPAWN_STATE_KEY, true);
}
/**
* Migrates this app's secrets from safeStorage+SQLite to the shared keychain.
* Idempotent — skips if already done.
*/
private async migrateSecrets(): Promise<void> {
if (!isMacintosh) {
return;
}
if (this.stateService.getItem<boolean>(MIGRATION_STATE_KEY, false)) {
if (this.isMigrationDone()) {
this.logService.trace('[CrossAppSecretSharing] Migration already done, skipping');
return;
}
// Wait for storage to be ready before we start — handleSecretResponse
// will write secrets into applicationStorage.
await this.applicationStorage.whenInit;
this.logService.info('[CrossAppSecretSharing] Starting shared keychain migration');
for (const key of CROSS_APP_SHARED_SECRET_KEYS) {
try {
const decrypted = await readEncryptedSecret(
key,
(fullKey) => this.applicationStorage.get(fullKey),
(value) => this.encryptionMainService.decrypt(value),
this.logService,
);
if (decrypted !== undefined) {
await this.sharedKeychainMainService.set(key, decrypted);
this.logService.trace('[CrossAppSecretSharing] Migrated key to shared keychain:', key);
}
} catch (err) {
this.logService.error('[CrossAppSecretSharing] Failed to migrate key:', key, err);
}
if (!this.crossAppIPCService.initialized) {
this.logService.info('[CrossAppSecretSharing] crossAppIPC not initialized, skipping migration');
return;
}
this.stateService.setItem(MIGRATION_STATE_KEY, true);
this.logService.info('[CrossAppSecretSharing] Migration complete');
this.logService.info('[CrossAppSecretSharing] Migration needed, starting...');
// Listen for connection — when connected, request secrets
this._register(this.crossAppIPCService.onDidConnect(isServer => {
this.logService.info(`[CrossAppSecretSharing] Connected (isServer=${isServer}), requesting secrets from host app`);
this.crossAppIPCService.sendMessage({ type: CrossAppSecretMessageType.SecretRequest });
}));
// Listen for messages
this._register(this.crossAppIPCService.onDidReceiveMessage(msg => {
const secretMsg = msg as CrossAppSecretMessage;
if (secretMsg?.type === CrossAppSecretMessageType.SecretResponse) {
this.handleSecretResponse(secretMsg.data ?? {});
}
}));
// If already connected (e.g. service was initialized before storage was ready),
// send the request immediately.
if (this.crossAppIPCService.connected) {
this.logService.info(`[CrossAppSecretSharing] Already connected (isServer=${this.crossAppIPCService.isServer}), requesting secrets from host app`);
this.crossAppIPCService.sendMessage({ type: CrossAppSecretMessageType.SecretRequest });
}
// Spawn Code.app with --share-secrets-with-agents-app
this.spawnHostApp();
// Timeout: if migration doesn't complete within 30s, give up
setTimeout(() => {
if (!this.isMigrationDone()) {
this.logService.warn('[CrossAppSecretSharing] Migration timed out');
}
}, 30_000);
}
private async initializeAsHostApp(onComplete?: () => void): Promise<void> {
if (!isMacintosh || this.isEmbeddedApp) {
onComplete?.();
return;
}
if (this.isMigrationDone()) {
this.logService.trace('[CrossAppSecretSharing] Migration already done, skipping');
onComplete?.();
return;
}
// Wait for application storage to be fully initialized before
// checking for secrets — storage may still be in-memory at this
// point during early startup.
await this.applicationStorage.whenInit;
if (!this.hasAnySharedSecrets()) {
this.logService.trace('[CrossAppSecretSharing] No shared secrets to share, skipping');
onComplete?.();
return;
}
if (!this.crossAppIPCService.initialized) {
this.logService.info('[CrossAppSecretSharing] crossAppIPC not initialized');
onComplete?.();
return;
}
this._onHostMigrationComplete = onComplete;
this.logService.info('[CrossAppSecretSharing] Host app responding to secret sharing request');
// Dispose previous listeners if initializeAsHostApp is called again
// (e.g. via repeated onDidRequestShareSecrets events).
this.hostHandshakeListeners.clear();
// Listen for messages from the agents app
this.hostHandshakeListeners.add(this.crossAppIPCService.onDidReceiveMessage(msg => {
const secretMsg = msg as CrossAppSecretMessage;
if (secretMsg?.type === CrossAppSecretMessageType.SecretRequest) {
this.handleSecretRequest();
} else if (secretMsg?.type === CrossAppSecretMessageType.SecretAck) {
this.handleSecretAck();
}
}));
// If disconnected before ack, still allow the host to quit
this.hostHandshakeListeners.add(this.crossAppIPCService.onDidDisconnect(() => {
this._onHostMigrationComplete?.();
this._onHostMigrationComplete = undefined;
}));
}
private isMigrationDone(): boolean {
return this.stateService.getItem<boolean>(MIGRATION_STATE_KEY, false);
}
private hasAnySharedSecrets(): boolean {
for (const key of CROSS_APP_SHARED_SECRET_KEYS) {
if (this.applicationStorage.get(secretStorageKey(key)) !== undefined) {
return true;
}
}
return false;
}
private spawnHostApp(): void {
@@ -174,4 +247,69 @@ export class MacOSCrossAppSecretSharing extends Disposable {
});
child.unref();
}
private async handleSecretRequest(): Promise<void> {
this.logService.info('[CrossAppSecretSharing] Host app handling secret request');
const secrets: Record<string, string> = {};
for (const key of CROSS_APP_SHARED_SECRET_KEYS) {
try {
const decrypted = await readEncryptedSecret(
key,
(fullKey) => this.applicationStorage.get(fullKey),
(value) => this.encryptionMainService.decrypt(value),
this.logService,
);
if (decrypted !== undefined) {
secrets[key] = decrypted;
}
} catch (err) {
this.logService.error('[CrossAppSecretSharing] Failed to read secret for key:', key, err);
}
}
this.crossAppIPCService.sendMessage({ type: CrossAppSecretMessageType.SecretResponse, data: secrets });
this.logService.info('[CrossAppSecretSharing] Sent secrets response with', Object.keys(secrets).length, 'keys');
}
private async handleSecretResponse(secrets: Record<string, string>): Promise<void> {
this.logService.info('[CrossAppSecretSharing] Agents app received', Object.keys(secrets).length, 'secrets');
for (const [key, value] of Object.entries(secrets)) {
if (!CROSS_APP_SHARED_SECRET_KEYS.includes(key)) {
this.logService.warn('[CrossAppSecretSharing] Ignoring unexpected key:', key);
continue;
}
try {
await writeEncryptedSecret(
key,
value,
(fullKey, encrypted) => this.applicationStorage.set(fullKey, encrypted),
(v) => this.encryptionMainService.encrypt(v),
this.logService,
);
} catch (err) {
this.logService.error('[CrossAppSecretSharing] Failed to store secret for key:', key, err);
}
}
this.stateService.setItem(MIGRATION_STATE_KEY, true);
this.logService.info('[CrossAppSecretSharing] Migration complete');
// Tell the host app migration is done so it can also record it.
// Don't close here — let the host close first after receiving the ack.
this.crossAppIPCService.sendMessage({ type: CrossAppSecretMessageType.SecretAck });
}
private handleSecretAck(): void {
this.stateService.setItem(MIGRATION_STATE_KEY, true);
this.logService.info('[CrossAppSecretSharing] Host app received ack, migration complete on both sides');
const onComplete = this._onHostMigrationComplete;
this._onHostMigrationComplete = undefined;
onComplete?.();
}
}

View File

@@ -1,92 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { isMacintosh } from '../../../base/common/platform.js';
import { ISharedKeychainMainService } from '../common/sharedKeychainService.js';
import { ILogService } from '../../log/common/log.js';
import { IProductService } from '../../product/common/productService.js';
type KeychainModule = typeof import('@vscode/macos-keychain');
export class SharedKeychainMainService implements ISharedKeychainMainService {
declare readonly _serviceBrand: undefined;
private _modulePromise: Promise<KeychainModule> | undefined;
private readonly serviceName: string;
private readonly enabled: boolean;
constructor(
@IProductService productService: IProductService,
@ILogService private readonly logService: ILogService,
) {
this.enabled = isMacintosh && !!productService.darwinSharedKeychainServiceName;
this.serviceName = productService.darwinSharedKeychainServiceName ?? '';
}
private getModule(): Promise<KeychainModule> {
if (!this._modulePromise) {
this._modulePromise = import('@vscode/macos-keychain');
}
return this._modulePromise;
}
async get(key: string): Promise<string | undefined> {
if (!this.enabled) {
return undefined;
}
try {
const mod = await this.getModule();
const value = mod.keychainGet(this.serviceName, key);
this.logService.trace('[SharedKeychainMainService] get:', key, value !== undefined ? '(found)' : '(not found)');
return value;
} catch (err) {
this.logService.error('[SharedKeychainMainService] get failed:', key, err);
return undefined;
}
}
async set(key: string, value: string): Promise<void> {
if (!this.enabled) {
return;
}
try {
const mod = await this.getModule();
mod.keychainSet(this.serviceName, key, value);
this.logService.trace('[SharedKeychainMainService] set:', key);
} catch (err) {
this.logService.error('[SharedKeychainMainService] set failed:', key, err);
}
}
async delete(key: string): Promise<boolean> {
if (!this.enabled) {
return false;
}
try {
const mod = await this.getModule();
const deleted = mod.keychainDelete(this.serviceName, key);
this.logService.trace('[SharedKeychainMainService] delete:', key, deleted ? '(deleted)' : '(not found)');
return deleted;
} catch (err) {
this.logService.error('[SharedKeychainMainService] delete failed:', key, err);
return false;
}
}
async keys(): Promise<string[]> {
if (!this.enabled) {
return [];
}
try {
const mod = await this.getModule();
const result = mod.keychainList(this.serviceName);
this.logService.trace('[SharedKeychainMainService] keys: found', result.length, 'entries');
return result;
} catch (err) {
this.logService.error('[SharedKeychainMainService] keys failed:', err);
return [];
}
}
}

View File

@@ -58,7 +58,6 @@ import '../workbench/services/mcp/electron-browser/mcpWorkbenchManagementService
import '../workbench/services/encryption/electron-browser/encryptionService.js';
import '../workbench/services/imageResize/electron-browser/imageResizeService.js';
import '../workbench/services/secrets/electron-browser/secretStorageService.js';
import '../workbench/services/secrets/electron-browser/sharedKeychainService.js';
import '../workbench/services/localization/electron-browser/languagePackService.js';
import '../workbench/services/telemetry/electron-browser/telemetryService.js';
import '../workbench/services/extensions/electron-browser/extensionHostStarter.js';

View File

@@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import { createSingleCallFunction } from '../../../../base/common/functional.js';
import { isLinux, isMacintosh } from '../../../../base/common/platform.js';
import { isLinux } from '../../../../base/common/platform.js';
import Severity from '../../../../base/common/severity.js';
import { localize } from '../../../../nls.js';
import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';
@@ -14,8 +14,7 @@ import { InstantiationType, registerSingleton } from '../../../../platform/insta
import { ILogService } from '../../../../platform/log/common/log.js';
import { INotificationService, IPromptChoice } from '../../../../platform/notification/common/notification.js';
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
import { BaseSecretStorageService, CROSS_APP_SHARED_SECRET_KEYS, ISecretStorageService } from '../../../../platform/secrets/common/secrets.js';
import { ISharedKeychainService } from '../../../../platform/secrets/common/sharedKeychainService.js';
import { BaseSecretStorageService, ISecretStorageService } from '../../../../platform/secrets/common/secrets.js';
import { IStorageService } from '../../../../platform/storage/common/storage.js';
import { IJSONEditingService } from '../../configuration/common/jsonEditing.js';
@@ -27,7 +26,6 @@ export class NativeSecretStorageService extends BaseSecretStorageService {
@IOpenerService private readonly _openerService: IOpenerService,
@IJSONEditingService private readonly _jsonEditingService: IJSONEditingService,
@INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService,
@ISharedKeychainService private readonly _sharedKeychainService: ISharedKeychainService,
@IStorageService storageService: IStorageService,
@IEncryptionService encryptionService: IEncryptionService,
@ILogService logService: ILogService
@@ -40,20 +38,6 @@ export class NativeSecretStorageService extends BaseSecretStorageService {
);
}
override get(key: string): Promise<string | undefined> {
return this._sequencer.queue(key, async () => {
if (isMacintosh && this.type !== 'in-memory' && CROSS_APP_SHARED_SECRET_KEYS.includes(key)) {
// Try shared keychain first
const value = await this._sharedKeychainService.get(key);
if (value !== undefined) {
return value;
}
}
// Fall back to old safeStorage+SQLite pipeline
return this._doGet(key);
});
}
override set(key: string, value: string): Promise<void> {
this._sequencer.queue(key, async () => {
await this.resolvedStorageService;
@@ -62,42 +46,10 @@ export class NativeSecretStorageService extends BaseSecretStorageService {
this._logService.trace('[NativeSecretStorageService] Notifying user that secrets are not being stored on disk.');
await this.notifyOfNoEncryptionOnce();
}
});
return this._sequencer.queue(key, async () => {
if (isMacintosh && this.type !== 'in-memory' && CROSS_APP_SHARED_SECRET_KEYS.includes(key)) {
// Write to shared keychain
await this._sharedKeychainService.set(key, value);
}
// Also write to legacy pipeline
await this._doSet(key, value);
});
}
override delete(key: string): Promise<void> {
return this._sequencer.queue(key, async () => {
if (isMacintosh && this.type !== 'in-memory' && CROSS_APP_SHARED_SECRET_KEYS.includes(key)) {
// Delete from shared keychain
await this._sharedKeychainService.delete(key);
}
// Delete from legacy pipeline
await this._doDelete(key);
});
}
override async keys(): Promise<string[]> {
return this._sequencer.queue('__keys__', async () => {
const legacyKeys = await this._doGetKeys();
if (isMacintosh && this.type !== 'in-memory') {
// Include any cross-app shared keys present in the shared keychain
for (const sharedKey of CROSS_APP_SHARED_SECRET_KEYS) {
const sharedValue = await this._sharedKeychainService.get(sharedKey);
if (sharedValue !== undefined && !legacyKeys.includes(sharedKey)) {
legacyKeys.push(sharedKey);
}
}
}
return legacyKeys;
});
return super.set(key, value);
}
private notifyOfNoEncryptionOnce = createSingleCallFunction(() => this.notifyOfNoEncryption());

View File

@@ -1,9 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { registerMainProcessRemoteService } from '../../../../platform/ipc/electron-browser/services.js';
import { ISharedKeychainService } from '../../../../platform/secrets/common/sharedKeychainService.js';
registerMainProcessRemoteService(ISharedKeychainService, 'sharedKeychain');

View File

@@ -59,7 +59,6 @@ import './services/mcp/electron-browser/mcpWorkbenchManagementService.js';
import './services/encryption/electron-browser/encryptionService.js';
import './services/imageResize/electron-browser/imageResizeService.js';
import './services/secrets/electron-browser/secretStorageService.js';
import './services/secrets/electron-browser/sharedKeychainService.js';
import './services/localization/electron-browser/languagePackService.js';
import './services/telemetry/electron-browser/telemetryService.js';
import './services/extensions/electron-browser/extensionHostStarter.js';