Skip to content
4 changes: 3 additions & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ Each extension follows the standard VS Code extension structure with `package.js

Choose validation based on the scope and risk of the change. Large-scale builds and typechecking can be slow, and consume significant resources, so minimize their use. Prefer existing editor or watch-task diagnostics and the smallest targeted tests that cover the changed behavior. Do not start build or watch tasks, run broad type checks, or make type checking a prerequisite for targeted tests solely as a completion ritual.

Run a targeted type check or build when you are not fully confident in the change, and the change is broad or cross-cutting, it affects build or type configuration, or another validation step reports a compilation problem. Useful commands include:
When running in a VS Code editor window with a workspace folder, use the VS Code task tools for build and watch workflows: inspect the existing task output first, and run an existing task instead of invoking its equivalent shell command. Do not start a duplicate build or watch process when the workspace task already provides current diagnostics. Agents window chats and isolated worktree sessions may not have access to the editor's workspace tasks; use the repository commands directly in those contexts.

Run a targeted type check or build when you are not fully confident in the change, and the change is broad or cross-cutting, it affects build or type configuration, or another validation step reports a compilation problem. When task tools are unavailable or no suitable task exists, useful commands include:

- `npm run typecheck-client` for the main sources under `src/`
- `npm run gulp compile-extensions` for built-in extensions
Expand Down
20 changes: 20 additions & 0 deletions src/vs/platform/agentHost/common/agentHostSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -859,3 +859,23 @@ export const platformRootSchema = createSchema({
default: {},
}),
});

/**
* Root config keys the connected client re-pushes on every connect and
* reconnect, and which gate permission prompts or policy restrictions.
*
* These must NOT be restored from `agent-host-config.json` on startup. Their
* persisted value is a snapshot of one client's settings, so reviving it would
* re-grant approvals that a user, workspace, or policy tightened while the host
* was stopped. Falling back to the schema default until the client republishes
* is the fail-safe direction.
*/
export const clientOwnedApprovalRootConfigKeys: ReadonlySet<string> = new Set([
SessionConfigKey.Permissions,
AgentHostGlobalAutoApproveEnabledConfigKey,
AgentHostAutoApprovePolicyRestrictedConfigKey,
AgentHostTerminalAutoApproveEnabledConfigKey,
AgentHostTerminalAutoApproveRulesConfigKey,
AgentHostEditAutoApprovePatternsConfigKey,
AgentHostAutoReplyEnabledConfigKey,
]);
20 changes: 19 additions & 1 deletion src/vs/platform/agentHost/node/agentConfigurationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { getAgentCustomizationSettingsEntries, getProviderBackedRootConfigKeys,
import { copilotCliConfigSchema } from '../common/copilotCliConfig.js';
import { agentMergeRootConfigSchema } from '../common/agentMerge.js';
import { sandboxConfigSchema } from '../common/sandboxConfigSchema.js';
import { agentHostProxyConfigSchema, type ISchema, type SchemaDefinition, type SchemaValue } from '../common/agentHostSchema.js';
import { agentHostProxyConfigSchema, clientOwnedApprovalRootConfigKeys, platformRootSchema, type ISchema, type SchemaDefinition, type SchemaValue } from '../common/agentHostSchema.js';
import { ProtocolError } from '../common/state/sessionProtocol.js';
import { ActionType, type ActionOrigin } from '../common/state/sessionActions.js';
import { isAhpChatChannel, parseSubagentSessionUri, ROOT_STATE_URI, type URI as ProtocolURI } from '../common/state/sessionState.js';
Expand Down Expand Up @@ -407,6 +407,7 @@ export class AgentConfigurationService extends Disposable implements IAgentConfi
const raw = fs.readFileSync(this._rootConfigResource.fsPath, 'utf8');
const parsed = JSON.parse(raw) as Record<string, unknown>;
return {
...this._loadPersistedPlatformRootConfig(parsed),
...agentHostCustomizationConfigSchema.validateOrDefault(parsed, defaults),
...sandboxConfigSchema.validateOrDefault(parsed, {}),
...copilotCliConfigSchema.validateOrDefault(parsed, {}),
Expand All @@ -421,4 +422,21 @@ export class AgentConfigurationService extends Disposable implements IAgentConfi
return { ...defaults };
}
}

/**
* Restores the platform-owned half of the persisted bag. The host reads
* some of these before any client connects (`showExternalSessions`, the
* migrate-legacy gate, provider enablement), so without this a restart
* runs its first pass against the schema default.
*/
private _loadPersistedPlatformRootConfig(parsed: Record<string, unknown>): Record<string, unknown> {
const values: Record<string, unknown> = { ...platformRootSchema.validateOrDefault(parsed, {}) };
// Approval and policy values are a snapshot of one client's settings and
// are re-pushed on every connect, so restoring them could re-grant an
// approval that was tightened while the host was stopped.
for (const key of clientOwnedApprovalRootConfigKeys) {
delete values[key];
}
return values;
}
}
75 changes: 66 additions & 9 deletions src/vs/platform/agentHost/node/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ const SESSION_GC_GRACE_MS = 30_000;
const DAY_MS = 24 * 60 * 60 * 1000;
const EXTERNAL_SESSION_MAX_AGE_MS = 30 * DAY_MS;
const RECENT_EXTERNAL_SESSION_LIMIT = 2;
/**
* How many locally created sessions must postdate an external session's last
* update before {@link AgentHostExternalSessionsMode.Recent} stops surfacing it.
*/
const RECENT_EXTERNAL_SUPERSEDING_LOCAL_LIMIT = 2;
/** A catalog pass slower than this is logged at info, since it delays every session-list refresh. */
const SLOW_LIST_SESSIONS_THRESHOLD_MS = 1_000;

Expand Down Expand Up @@ -683,6 +688,9 @@ export class AgentService extends Disposable implements IAgentService {
if (nextMode !== externalSessionsMode) {
const previousMode = externalSessionsMode;
externalSessionsMode = nextMode;
// The only point past startup where `Recent` re-measures the
// superseding local sessions.
this._invalidateRecentSupersedingCutoff();
this._logService.info(`[AgentService] ${AgentHostShowExternalSessionsConfigKey} changed '${previousMode}' -> '${nextMode}'; queueing session list reconciliation`);
this._queueSessionListReconciliation(previousMode);
}
Expand Down Expand Up @@ -2090,7 +2098,7 @@ export class AgentService extends Disposable implements IAgentService {
const combined = additions.length > 0 ? [...withStatus, ...additions] : withStatus;
const now = Date.now();
const recentSessionKeys = mode === AgentHostExternalSessionsMode.Recent
? this._getRecentSessionKeys(combined, now)
? this._getRecentSessionKeys(combined, now, this._resolveRecentSupersedingCutoff(allRegistered, epoch))
: undefined;
const visible: IAgentSessionMetadata[] = [];
// Adoptable-legacy rows are withheld by migrate-legacy, not by the external mode.
Expand Down Expand Up @@ -2153,11 +2161,12 @@ export class AgentService extends Disposable implements IAgentService {
return modifiedTime < now - EXTERNAL_SESSION_MAX_AGE_MS;
}

private _getRecentSessionKeys(sessions: readonly IAgentSessionMetadata[], now: number): ReadonlySet<string> {
private _getRecentSessionKeys(sessions: readonly IAgentSessionMetadata[], now: number, supersededBefore: number | undefined): ReadonlySet<string> {
const recentExternalSessions = sessions
.filter(session => readSessionExternal(session._meta)
&& !readSessionEhcliAdoptable(session._meta)
&& session.modifiedTime >= now - 7 * DAY_MS)
&& session.modifiedTime >= now - 7 * DAY_MS
&& (supersededBefore === undefined || session.modifiedTime >= supersededBefore))
.sort((a, b) => {
const timeDifference = b.modifiedTime - a.modifiedTime;
if (timeDifference !== 0) {
Expand All @@ -2171,6 +2180,49 @@ export class AgentService extends Disposable implements IAgentService {
return new Set(recentExternalSessions.map(session => session.session.toString()));
}

/**
* Start time of the {@link RECENT_EXTERNAL_SUPERSEDING_LOCAL_LIMIT}-th most
* recently created local session, or `undefined` while fewer exist. `Recent`
* drops external sessions last updated before it.
*/
private _recentSupersedingCutoff: number | undefined;
private _hasRecentSupersedingCutoff = false;

/**
* Snapshots the cutoff from the registry, which — unlike the hydrated
* metadata — never drops a local session because its provider is
* unavailable or its metadata read failed. Sending a first message
* materializes a local session, so a per-listing cutoff would rotate an
* external row out of the list mid-use. Committed only while `epoch` still
* holds, so a discarded pass cannot freeze an undercounted value.
*/
private _resolveRecentSupersedingCutoff(registered: readonly IRegisteredSession[], epoch: number): number | undefined {
if (this._hasRecentSupersedingCutoff) {
return this._recentSupersedingCutoff;
}
// Idle provisional sessions are the composer's eagerly-created
// placeholder, not sessions the user started.
const localStartTimes = registered
.filter(entry => !entry.external
&& Number.isFinite(entry.startTime)
&& !this._stateManager.isIdleProvisionalSession(entry.session.toString()))
.map(entry => entry.startTime)
.sort((a, b) => b - a);
const cutoff = localStartTimes.length >= RECENT_EXTERNAL_SUPERSEDING_LOCAL_LIMIT
? localStartTimes[RECENT_EXTERNAL_SUPERSEDING_LOCAL_LIMIT - 1]
: undefined;
if (epoch === this._registryEpoch) {
this._recentSupersedingCutoff = cutoff;
this._hasRecentSupersedingCutoff = true;
}
return cutoff;
}

private _invalidateRecentSupersedingCutoff(): void {
this._hasRecentSupersedingCutoff = false;
this._recentSupersedingCutoff = undefined;
}

private _shouldIncludeSession(
session: IAgentSessionMetadata,
mode = this._getExternalSessionsMode(),
Expand Down Expand Up @@ -2328,7 +2380,7 @@ export class AgentService extends Disposable implements IAgentService {
previouslyExposed.add(session);
}
const listed = previousMode !== undefined
? this._resolveModeChangeVisibility(await this.listSessions(AgentHostExternalSessionsMode.Last30Days), previousMode, previouslyExposed)
? await this._resolveModeChangeVisibility(await this.listSessions(AgentHostExternalSessionsMode.Last30Days), previousMode, previouslyExposed)
: await this.listSessions();
const visible = new Set<string>();
let published = 0;
Expand Down Expand Up @@ -2382,14 +2434,20 @@ export class AgentService extends Disposable implements IAgentService {
* mode and the mode is just a parameter to {@link _shouldIncludeSession}.
* Adds what `previousMode` had exposed into `previouslyExposed`.
*/
private _resolveModeChangeVisibility(
private async _resolveModeChangeVisibility(
superset: readonly IAgentSessionMetadata[],
previousMode: AgentHostExternalSessionsMode,
previouslyExposed: Set<string>,
): IAgentSessionMetadata[] {
): Promise<IAgentSessionMetadata[]> {
const now = Date.now();
const recentKeysFor = (mode: AgentHostExternalSessionsMode) => mode === AgentHostExternalSessionsMode.Recent
? this._getRecentSessionKeys(superset, now)
const mode = this._getExternalSessionsMode();
// The pass above ran as `Last30Days`, so it never snapshotted the cutoff.
const epoch = this._registryEpoch;
const supersededBefore = previousMode === AgentHostExternalSessionsMode.Recent || mode === AgentHostExternalSessionsMode.Recent
? this._resolveRecentSupersedingCutoff(await this._listRegisteredSessions(), epoch)
: undefined;
const recentKeysFor = (candidate: AgentHostExternalSessionsMode) => candidate === AgentHostExternalSessionsMode.Recent
? this._getRecentSessionKeys(superset, now, supersededBefore)
: undefined;

const previousRecentKeys = recentKeysFor(previousMode);
Expand All @@ -2399,7 +2457,6 @@ export class AgentService extends Disposable implements IAgentService {
}
}

const mode = this._getExternalSessionsMode();
const recentKeys = recentKeysFor(mode);
const visible = superset.filter(session => this._shouldIncludeSession(session, mode, now, recentKeys));
// The pass ran as `Last30Days`, so report the mode actually in effect instead.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import { join } from '../../../../base/common/path.js';
import { URI } from '../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { NullLogService } from '../../../log/common/log.js';
import { AgentHostProxyConfigKey, createSchema, schemaProperty } from '../../common/agentHostSchema.js';
import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostAutoReplyEnabledConfigKey, AgentHostEditAutoApprovePatternsConfigKey, AgentHostExternalSessionsMode, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostMcpServersConfigKey, AgentHostProxyConfigKey, AgentHostShowExternalSessionsConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, clientOwnedApprovalRootConfigKeys, createSchema, platformRootSchema, schemaProperty } from '../../common/agentHostSchema.js';
import { AGENT_CUSTOMIZATION_SETTINGS_META_KEY, getAgentCustomizationSettingsEntries } from '../../common/agentCustomizationSettings.js';
import { SessionConfigKey } from '../../common/sessionConfigKeys.js';
import type { RootConfigState } from '../../common/state/protocol/state.js';
import { ActionType } from '../../common/state/sessionActions.js';
import { buildChatUri, buildSubagentSessionUri, SessionStatus, type SessionSummary } from '../../common/state/sessionState.js';
Expand Down Expand Up @@ -284,6 +285,66 @@ suite('AgentConfigurationService', () => {
fs.rmSync(directory, { recursive: true, force: true });
});

test('restores persisted platform root settings when the host restarts', async () => {
const directory = fs.mkdtempSync(join(os.tmpdir(), 'agent-config-'));
const resource = URI.file(join(directory, 'agent-host-config.json'));
const firstManager = disposables.add(new AgentHostStateManager(new NullLogService()));
const firstService = disposables.add(new AgentConfigurationService(firstManager, new NullLogService(), resource));
firstService.updateRootConfig({
[AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days,
[AgentHostMcpServersConfigKey]: { operatorServer: { command: 'node' } },
});
await firstService.whenIdle();

const restartedManager = disposables.add(new AgentHostStateManager(new NullLogService()));
const restartedService = disposables.add(new AgentConfigurationService(restartedManager, new NullLogService(), resource));

assert.deepStrictEqual({
showExternalSessions: restartedService.getRootValue(platformRootSchema, AgentHostShowExternalSessionsConfigKey),
mcpServers: restartedService.getRootValue(platformRootSchema, AgentHostMcpServersConfigKey),
}, {
showExternalSessions: AgentHostExternalSessionsMode.Last30Days,
mcpServers: { operatorServer: { command: 'node' } },
});
fs.rmSync(directory, { recursive: true, force: true });
});

test('does not restore client-owned approval settings when the host restarts', async () => {
const directory = fs.mkdtempSync(join(os.tmpdir(), 'agent-config-'));
const resource = URI.file(join(directory, 'agent-host-config.json'));
const firstManager = disposables.add(new AgentHostStateManager(new NullLogService()));
const firstService = disposables.add(new AgentConfigurationService(firstManager, new NullLogService(), resource));
// A permissive snapshot that a user, workspace, or policy could tighten
// while the host is stopped.
firstService.updateRootConfig({
[SessionConfigKey.Permissions]: { allow: ['revoked-rule'], deny: [] },
[AgentHostGlobalAutoApproveEnabledConfigKey]: true,
[AgentHostAutoApprovePolicyRestrictedConfigKey]: false,
[AgentHostTerminalAutoApproveEnabledConfigKey]: true,
[AgentHostTerminalAutoApproveRulesConfigKey]: { rm: true },
[AgentHostEditAutoApprovePatternsConfigKey]: { '**/*': true },
[AgentHostAutoReplyEnabledConfigKey]: true,
});
await firstService.whenIdle();

const persisted = JSON.parse(fs.readFileSync(resource.fsPath, 'utf8')) as Record<string, unknown>;
const restartedManager = disposables.add(new AgentHostStateManager(new NullLogService()));
const restartedService = disposables.add(new AgentConfigurationService(restartedManager, new NullLogService(), resource));
const restored = restartedService.getRootConfigValues();

assert.deepStrictEqual({
persistedKeys: [...clientOwnedApprovalRootConfigKeys].filter(key => persisted[key] !== undefined).sort(),
// The state manager seeds empty permissions; nothing else survives.
restoredKeys: [...clientOwnedApprovalRootConfigKeys].filter(key => restored[key] !== undefined).sort(),
permissions: restored[SessionConfigKey.Permissions],
}, {
persistedKeys: [...clientOwnedApprovalRootConfigKeys].sort(),
restoredKeys: [SessionConfigKey.Permissions],
permissions: { allow: [], deny: [] },
});
fs.rmSync(directory, { recursive: true, force: true });
});

test('seeds provider configuration into the initial root snapshot', () => {
const localManager = disposables.add(new AgentHostStateManager(new NullLogService()));
disposables.add(new AgentConfigurationService(localManager, new NullLogService(), undefined, [{
Expand Down
Loading
Loading