Skip to content

Commit 748fa72

Browse files
samclark2015claude
andauthored
Add positron.workspace.registerConfigurationMigrations extension API (#14298)
Adds `positron.workspace.registerConfigurationMigrations` to the Positron extension API, allowing extensions to declaratively migrate configuration keys they previously contributed. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b860849 commit 748fa72

9 files changed

Lines changed: 280 additions & 1 deletion

File tree

product.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,6 +691,7 @@
691691
"languageModelSystem"
692692
]
693693
},
694+
"trustedExtensionPublishers": ["posit", "rstudio"],
694695
"trustedExtensionAuthAccess": {
695696
"github": [],
696697
"github-enterprise": [

src/positron-dts/positron.d.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3997,4 +3997,40 @@ declare module 'positron' {
39973997
*/
39983998
export function clearCellOutputs(notebookUri: string, cellIndices?: number[]): Thenable<void>;
39993999
}
4000+
4001+
/**
4002+
* A specification for migrating one configuration key to another.
4003+
* Used with {@link workspace.registerConfigurationMigrations}.
4004+
*/
4005+
export interface ConfigurationMigrationSpec {
4006+
/** The configuration key to migrate from. */
4007+
readonly key: string;
4008+
/** The configuration key to migrate to. */
4009+
readonly migrateTo: string;
4010+
}
4011+
4012+
export namespace workspace {
4013+
4014+
/**
4015+
* Register configuration key migrations. Each migration copies a value from an old
4016+
* configuration key to a new key and clears the old key. If the destination key
4017+
* already has a user-set value at the same configuration target, the old key is
4018+
* cleared without overwriting the new value.
4019+
*
4020+
* Ownership of the source keys is required and determined in order:
4021+
* 1. The key is still registered and its `source` matches the calling extension.
4022+
* 2. The key has no registered extension owner (e.g. removed from the manifest after
4023+
* renaming) and the key starts with `{extensionId}.` — namespace ownership.
4024+
*
4025+
* Ownership checks are bypassed entirely for extensions published by `posit`.
4026+
*
4027+
* If the old key is enforced by system policy but the new key is not, the migration
4028+
* will log an error and notify the user; the admin should update the policy to use
4029+
* the new key.
4030+
*
4031+
* @param migrations Array of migration specifications.
4032+
* @returns A {@link vscode.Disposable} (unregistering is not supported; dispose is a no-op).
4033+
*/
4034+
export function registerConfigurationMigrations(migrations: ReadonlyArray<ConfigurationMigrationSpec>): vscode.Disposable;
4035+
}
40004036
}

src/vs/workbench/api/browser/mainThreadConfiguration.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ import { MainThreadConfigurationShape, MainContext, ExtHostContext, IConfigurati
1212
import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js';
1313
import { ConfigurationTarget, IConfigurationService, IConfigurationOverrides } from '../../../platform/configuration/common/configuration.js';
1414
import { IEnvironmentService } from '../../../platform/environment/common/environment.js';
15+
// --- Start Positron ---
16+
import * as nls from '../../../nls.js';
17+
import { ILogService } from '../../../platform/log/common/log.js';
18+
import { INotificationService, Severity } from '../../../platform/notification/common/notification.js';
19+
import { IProductService } from '../../../platform/product/common/productService.js';
20+
import { Extensions as ConfigurationMigrationExtensions, IConfigurationMigrationRegistry, ConfigurationKeyValuePairs } from '../../common/configuration.js';
21+
// --- End Positron ---
1522

1623
@extHostNamedCustomer(MainContext.MainThreadConfiguration)
1724
export class MainThreadConfiguration implements MainThreadConfigurationShape {
@@ -23,6 +30,11 @@ export class MainThreadConfiguration implements MainThreadConfigurationShape {
2330
@IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService,
2431
@IConfigurationService private readonly configurationService: IConfigurationService,
2532
@IEnvironmentService private readonly _environmentService: IEnvironmentService,
33+
// --- Start Positron ---
34+
@ILogService private readonly _logService: ILogService,
35+
@INotificationService private readonly _notificationService: INotificationService,
36+
@IProductService private readonly _productService: IProductService,
37+
// --- End Positron ---
2638
) {
2739
const proxy = extHostContext.getProxy(ExtHostContext.ExtHostConfiguration);
2840

@@ -41,6 +53,72 @@ export class MainThreadConfiguration implements MainThreadConfigurationShape {
4153
return configurationData;
4254
}
4355

56+
// --- Start Positron ---
57+
$registerConfigurationMigrations(extensionId: string, migrations: ReadonlyArray<{ readonly key: string; readonly migrateTo: string }>): void {
58+
const configurationProperties = Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).getConfigurationProperties();
59+
const normalizedExtensionId = extensionId.toLowerCase();
60+
const publisher = normalizedExtensionId.split('.')[0];
61+
const isTrusted = (this._productService.trustedExtensionPublishers ?? []).includes(publisher);
62+
63+
const approved = migrations.filter(migration => {
64+
const source = configurationProperties[migration.key]?.source;
65+
const sourceId = (source && typeof source !== 'string')
66+
? source.id.toLowerCase()
67+
: undefined;
68+
const isRegisteredOwner = sourceId === normalizedExtensionId;
69+
// When the key has no extension owner (unregistered after a rename, or never attributed),
70+
// accept ownership if the key falls within the extension's namespace.
71+
const isNamespaceOwner = sourceId === undefined && migration.key.toLowerCase().startsWith(normalizedExtensionId + '.');
72+
const isOwner = isRegisteredOwner || isNamespaceOwner;
73+
if (!isOwner && !isTrusted) {
74+
this._logService.warn(`Extension '${extensionId}' attempted to register a configuration migration for '${migration.key}' but does not own it.`);
75+
return false;
76+
}
77+
return true;
78+
});
79+
80+
const policyConflicts = approved.filter(migration => {
81+
const oldPolicyValue = this.configurationService.inspect(migration.key)?.policyValue;
82+
const newPolicyValue = this.configurationService.inspect(migration.migrateTo)?.policyValue;
83+
return oldPolicyValue !== undefined && newPolicyValue === undefined;
84+
});
85+
86+
for (const migration of policyConflicts) {
87+
this._logService.error(
88+
`Admin policy enforces '${migration.key}' but not '${migration.migrateTo}'. ` +
89+
`Migration registered by '${extensionId}' may not behave correctly.`
90+
);
91+
}
92+
93+
if (policyConflicts.length > 0) {
94+
const keyList = policyConflicts.map(m => `'${m.key}'`).join(', ');
95+
this._notificationService.notify({
96+
severity: Severity.Warning,
97+
message: nls.localize(
98+
'positron.configurationMigration.policyConflict',
99+
"Some configuration migrations registered by '{0}' are blocked by system policy ({1}). Contact your administrator to update the policy.",
100+
extensionId,
101+
keyList,
102+
),
103+
});
104+
}
105+
106+
if (approved.length > 0) {
107+
Registry.as<IConfigurationMigrationRegistry>(ConfigurationMigrationExtensions.ConfigurationMigration)
108+
.registerConfigurationMigrations(approved.map(migration => ({
109+
key: migration.key,
110+
migrateFn: (value: unknown, accessor: (key: string) => unknown): ConfigurationKeyValuePairs => {
111+
const pairs: ConfigurationKeyValuePairs = [[migration.key, { value: undefined }]];
112+
if (value !== undefined && accessor(migration.migrateTo) === undefined) {
113+
pairs.push([migration.migrateTo, { value }]);
114+
}
115+
return pairs;
116+
},
117+
})));
118+
}
119+
}
120+
// --- End Positron ---
121+
44122
public dispose(): void {
45123
this._configurationListener.dispose();
46124
}

src/vs/workbench/api/common/extHost.protocol.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,9 @@ export interface MainThreadSecretStateShape extends IDisposable {
247247
export interface MainThreadConfigurationShape extends IDisposable {
248248
$updateConfigurationOption(target: ConfigurationTarget | null, key: string, value: unknown, overrides: IConfigurationOverrides | undefined, scopeToLanguage: boolean | undefined): Promise<void>;
249249
$removeConfigurationOption(target: ConfigurationTarget | null, key: string, overrides: IConfigurationOverrides | undefined, scopeToLanguage: boolean | undefined): Promise<void>;
250+
// --- Start Positron ---
251+
$registerConfigurationMigrations(extensionId: string, migrations: ReadonlyArray<{ readonly key: string; readonly migrateTo: string }>): void;
252+
// --- End Positron ---
250253
}
251254

252255
export interface MainThreadDiagnosticsShape extends IDisposable {

src/vs/workbench/api/common/extHostConfiguration.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,12 @@ export class ExtHostConfiguration implements ExtHostConfigurationShape {
134134
$acceptConfigurationChanged(data: IConfigurationInitData, change: IConfigurationChange): void {
135135
this.getConfigProvider().then(provider => provider.$acceptConfigurationChanged(data, change));
136136
}
137+
138+
// --- Start Positron ---
139+
registerConfigurationMigrations(extension: IExtensionDescription, migrations: ReadonlyArray<{ readonly key: string; readonly migrateTo: string }>): void {
140+
this._proxy.$registerConfigurationMigrations(extension.identifier.value, migrations);
141+
}
142+
// --- End Positron ---
137143
}
138144

139145
export class ExtHostConfigProvider {

src/vs/workbench/api/common/positron/extHost.positron.api.impl.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { ILogService } from '../../../../platform/log/common/log.js';
1212
import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
1313
import { IExtensionRegistries } from '../extHost.api.impl.js';
1414
import { IExtensionDescription } from '../../../../platform/extensions/common/extensions.js';
15-
import { ExtHostConfigProvider } from '../extHostConfiguration.js';
15+
import { ExtHostConfigProvider, IExtHostConfiguration } from '../extHostConfiguration.js';
1616
import { ExtHostPositronContext } from './extHost.positron.protocol.js';
1717
import * as extHostTypes from './extHostTypes.positron.js';
1818
import { NotebookCellType } from '../../../common/positron/notebookAssistant.js';
@@ -64,6 +64,7 @@ export function createPositronApiFactoryAndRegisterActors(accessor: ServicesAcce
6464
const extHostWorkspace = accessor.get(IExtHostWorkspace);
6565
const extHostCommands = accessor.get(IExtHostCommands);
6666
const extHostLogService = accessor.get(ILogService);
67+
const extHostConfiguration = accessor.get(IExtHostConfiguration);
6768

6869
// Retrieve the raw `ExtHostWebViews` object from the rpcProtocol; this
6970
// object is needed to create webviews, and was previously created in
@@ -618,6 +619,13 @@ export function createPositronApiFactoryAndRegisterActors(accessor: ServicesAcce
618619
}
619620
};
620621

622+
const workspace: typeof positron.workspace = {
623+
registerConfigurationMigrations(migrations: ReadonlyArray<positron.ConfigurationMigrationSpec>): vscode.Disposable {
624+
extHostConfiguration.registerConfigurationMigrations(extension, migrations);
625+
return { dispose: () => { } };
626+
},
627+
};
628+
621629
// --- End Positron ---
622630

623631
return {
@@ -635,6 +643,7 @@ export function createPositronApiFactoryAndRegisterActors(accessor: ServicesAcce
635643
dataExplorer,
636644
ai,
637645
notebooks,
646+
workspace,
638647
CodeAttributionSource: extHostTypes.CodeAttributionSource,
639648
PositronLanguageModelType: extHostTypes.PositronLanguageModelType,
640649
PositronChatAgentLocation: extHostTypes.PositronChatAgentLocation,

src/vs/workbench/api/test/browser/mainThreadConfiguration.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ import { IConfigurationService, ConfigurationTarget } from '../../../../platform
1616
import { WorkspaceService } from '../../../services/configuration/browser/configurationService.js';
1717
import { IEnvironmentService } from '../../../../platform/environment/common/environment.js';
1818
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
19+
// --- Start Positron ---
20+
import { ILogService, NullLogService } from '../../../../platform/log/common/log.js';
21+
import { INotificationService, NoOpNotification } from '../../../../platform/notification/common/notification.js';
22+
import { IProductService } from '../../../../platform/product/common/productService.js';
23+
import { Extensions as ConfigurationMigrationExtensions, IConfigurationMigrationRegistry } from '../../../common/configuration.js';
24+
// --- End Positron ---
1925

2026
suite('MainThreadConfiguration', function () {
2127

@@ -60,6 +66,10 @@ suite('MainThreadConfiguration', function () {
6066
instantiationService.stub(IEnvironmentService, {
6167
isBuilt: false
6268
});
69+
// --- Start Positron ---
70+
instantiationService.stub(ILogService, new NullLogService());
71+
instantiationService.stub(IProductService, { trustedExtensionPublishers: ['posit', 'rstudio'] });
72+
// --- End Positron ---
6373
});
6474

6575
teardown(() => {
@@ -236,4 +246,104 @@ suite('MainThreadConfiguration', function () {
236246

237247
assert.strictEqual(ConfigurationTarget.WORKSPACE_FOLDER, target.args[0][3]);
238248
});
249+
250+
// --- Start Positron ---
251+
suite('registerConfigurationMigrations', function () {
252+
253+
const OWNED_KEY = 'extHostConfigMigration.oldKey';
254+
const OWNER_EXT = 'test.owner';
255+
256+
suiteSetup(() => {
257+
Registry.as<IConfigurationRegistry>(Extensions.Configuration).registerConfiguration({
258+
id: 'extHostConfigMigration',
259+
type: 'object',
260+
extensionInfo: { id: OWNER_EXT },
261+
properties: {
262+
[OWNED_KEY]: { type: 'boolean', description: 'test key for migration tests' }
263+
}
264+
});
265+
});
266+
267+
let migrationRegistry: IConfigurationMigrationRegistry;
268+
let registerSpy: sinon.SinonSpy;
269+
let warnSpy: sinon.SinonSpy;
270+
271+
setup(() => {
272+
migrationRegistry = Registry.as<IConfigurationMigrationRegistry>(ConfigurationMigrationExtensions.ConfigurationMigration);
273+
registerSpy = sinon.spy(migrationRegistry, 'registerConfigurationMigrations');
274+
const logService = new NullLogService();
275+
warnSpy = sinon.spy(logService, 'warn');
276+
instantiationService.stub(ILogService, logService);
277+
instantiationService.stub(INotificationService, { notify: () => new NoOpNotification() });
278+
instantiationService.stub(IWorkspaceContextService, <IWorkspaceContextService>{ getWorkbenchState: () => WorkbenchState.FOLDER });
279+
});
280+
281+
teardown(() => {
282+
registerSpy.restore();
283+
});
284+
285+
test('owned key is accepted and migrateFn maps value correctly', function () {
286+
const testObject = instantiationService.createInstance(MainThreadConfiguration, SingleProxyRPCProtocol(proxy));
287+
288+
testObject.$registerConfigurationMigrations(OWNER_EXT, [{ key: OWNED_KEY, migrateTo: 'extHostConfigMigration.newKey' }]);
289+
290+
assert.ok(registerSpy.calledOnce, 'registerConfigurationMigrations should be called once');
291+
const [migrations] = registerSpy.args[0] as [Array<{ key: string; migrateFn: (v: unknown, accessor: (k: string) => unknown) => unknown }>];
292+
assert.strictEqual(migrations.length, 1);
293+
assert.strictEqual(migrations[0].key, OWNED_KEY);
294+
// accessor returns undefined → new key not yet set → migration copies value
295+
const result = migrations[0].migrateFn('testValue', () => undefined);
296+
assert.deepStrictEqual(result, [
297+
[OWNED_KEY, { value: undefined }],
298+
['extHostConfigMigration.newKey', { value: 'testValue' }],
299+
]);
300+
});
301+
302+
test('unowned key is rejected and a warning is logged', function () {
303+
const testObject = instantiationService.createInstance(MainThreadConfiguration, SingleProxyRPCProtocol(proxy));
304+
305+
testObject.$registerConfigurationMigrations('other.extension', [{ key: OWNED_KEY, migrateTo: 'extHostConfigMigration.newKey' }]);
306+
307+
assert.ok(warnSpy.calledOnce, 'warn should be called for unowned key');
308+
assert.ok(registerSpy.notCalled, 'registerConfigurationMigrations should not be called');
309+
});
310+
311+
test('trusted publisher bypasses ownership check', function () {
312+
const testObject = instantiationService.createInstance(MainThreadConfiguration, SingleProxyRPCProtocol(proxy));
313+
314+
testObject.$registerConfigurationMigrations('posit.extension', [{ key: OWNED_KEY, migrateTo: 'extHostConfigMigration.newKey' }]);
315+
316+
assert.ok(registerSpy.calledOnce, 'posit publisher should be able to migrate unowned key');
317+
assert.ok(warnSpy.notCalled, 'no warning should be logged for posit publisher');
318+
319+
registerSpy.resetHistory();
320+
warnSpy.resetHistory();
321+
322+
testObject.$registerConfigurationMigrations('rstudio.rstudio-workbench', [{ key: OWNED_KEY, migrateTo: 'extHostConfigMigration.newKey' }]);
323+
324+
assert.ok(registerSpy.calledOnce, 'rstudio publisher should be able to migrate unowned key');
325+
assert.ok(warnSpy.notCalled, 'no warning should be logged for rstudio publisher');
326+
});
327+
328+
test('unregistered key is accepted when it matches extension namespace', function () {
329+
const testObject = instantiationService.createInstance(MainThreadConfiguration, SingleProxyRPCProtocol(proxy));
330+
const droppedKey = `${OWNER_EXT}.droppedKey`; // never registered; simulates rename-and-remove
331+
332+
testObject.$registerConfigurationMigrations(OWNER_EXT, [{ key: droppedKey, migrateTo: 'extHostConfigMigration.newKey' }]);
333+
334+
assert.ok(registerSpy.calledOnce, 'migration for unregistered key in extension namespace should be accepted');
335+
assert.ok(warnSpy.notCalled, 'no warning should be logged for namespace-owned key');
336+
});
337+
338+
test('unregistered key outside extension namespace is rejected', function () {
339+
const testObject = instantiationService.createInstance(MainThreadConfiguration, SingleProxyRPCProtocol(proxy));
340+
const foreignKey = 'other.publisher.droppedKey'; // unregistered and wrong namespace
341+
342+
testObject.$registerConfigurationMigrations(OWNER_EXT, [{ key: foreignKey, migrateTo: 'extHostConfigMigration.newKey' }]);
343+
344+
assert.ok(warnSpy.calledOnce, 'warn should be called for unregistered key outside extension namespace');
345+
assert.ok(registerSpy.notCalled, 'migration should not be registered');
346+
});
347+
});
348+
// --- End Positron ---
239349
});

src/vs/workbench/common/configuration.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ class ConfigurationMigrationRegistry implements IConfigurationMigrationRegistry
7777

7878
registerConfigurationMigrations(configurationMigrations: ConfigurationMigration[]): void {
7979
this.migrations.push(...configurationMigrations);
80+
// --- Start Positron ---
81+
// Fire the event so ConfigurationMigrationWorkbenchContribution processes dynamically registered migrations.
82+
this._onDidRegisterConfigurationMigrations.fire(configurationMigrations);
83+
// --- End Positron ---
8084
}
8185

8286
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (C) 2026 Posit Software, PBC. All rights reserved.
3+
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
/// <reference types="vitest/globals" />
7+
8+
import { Registry } from '../../../platform/registry/common/platform.js';
9+
import { Extensions, IConfigurationMigrationRegistry, ConfigurationMigration } from '../configuration.js';
10+
import { Event } from '../../../base/common/event.js';
11+
12+
describe('ConfigurationMigrationRegistry', () => {
13+
14+
it('fires onDidRegisterConfigurationMigration when migrations are registered', () => {
15+
const registry = Registry.as<IConfigurationMigrationRegistry & { onDidRegisterConfigurationMigration: Event<ConfigurationMigration[]> }>(Extensions.ConfigurationMigration);
16+
17+
const fired: ConfigurationMigration[][] = [];
18+
const disposable = registry.onDidRegisterConfigurationMigration(m => fired.push(m));
19+
20+
const migration: ConfigurationMigration = {
21+
key: 'test.eventFiringKey',
22+
migrateFn: (value: unknown) => [['test.eventFiringKeyNew', { value }]],
23+
};
24+
registry.registerConfigurationMigrations([migration]);
25+
26+
disposable.dispose();
27+
28+
expect(fired).toHaveLength(1);
29+
expect(fired[0]).toHaveLength(1);
30+
expect(fired[0][0].key).toBe('test.eventFiringKey');
31+
});
32+
});

0 commit comments

Comments
 (0)