Skip to content
Merged
Show file tree
Hide file tree
Changes from 39 commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
08f2a74
Add shell init snippet generators for the SDK shell tool
anthonykim1 Aug 25, 2026
8db3720
Add the shellInitSnippets session config key
anthonykim1 Aug 25, 2026
fc843c6
Let buildSandboxConfigForSdk grant extra readonly paths
anthonykim1 Aug 25, 2026
082d21a
Apply shell init scripts to SDK built-in shell tool sessions
anthonykim1 Aug 25, 2026
2aa094f
Publish Python activation and profile scripts for agent shell commands
anthonykim1 Aug 25, 2026
2334c6c
Reduce shell init integration to one script
anthonykim1 Aug 25, 2026
8c0cafc
Address shell init review feedback
anthonykim1 Aug 25, 2026
eafb955
Address latest shell init review
anthonykim1 Aug 25, 2026
c98d674
Make shell init test paths platform-neutral
anthonykim1 Aug 25, 2026
9961426
Only the owning window clears shell init; scope script files per inst…
anthonykim1 Aug 25, 2026
c709fbb
Load pwsh profiles under Continue; prune empty shell init session dirs
anthonykim1 Aug 25, 2026
fda9bb1
Drop the conda-specific rc replay from the shell init script
anthonykim1 Aug 26, 2026
540b51f
Read only the shell-matching activation variable; add SDK shell init e2e
anthonykim1 Aug 26, 2026
33bd3e3
Merge upstream main into anthonykim1/initScriptSDK
anthonykim1 Sep 1, 2026
d7c86cf
Default Agent Host shell init scripts off
anthonykim1 Sep 1, 2026
2d87d93
Correct Agent Host shell profile sourcing
anthonykim1 Sep 1, 2026
9e2dc4b
Keep Agent Host shell init state transient
anthonykim1 Sep 1, 2026
8b19d5f
Prevent shell init config update loops
anthonykim1 Sep 1, 2026
510e5b9
Encode PowerShell shell init payloads safely
anthonykim1 Sep 1, 2026
e022009
Reject malformed shell init config safely
anthonykim1 Sep 1, 2026
47589bf
Restrict shell init config to editor clients
anthonykim1 Sep 1, 2026
a7652b6
Document shell init script approval behavior
anthonykim1 Sep 1, 2026
c24669c
Cover first-turn shell init ordering
anthonykim1 Sep 1, 2026
bd366b7
Allow clients to clear shell init config
anthonykim1 Sep 1, 2026
f56b8f4
Apply shell init config before resumed turns
anthonykim1 Sep 1, 2026
afab8cf
Run the PowerShell shell init test on Windows
anthonykim1 Sep 1, 2026
fbee6c9
Close shell init config authorization bypasses
anthonykim1 Sep 1, 2026
8df3fca
Wait for pending shell init publication
anthonykim1 Sep 1, 2026
8d3a0fd
Serialize shell init config publications
anthonykim1 Sep 1, 2026
6cf6c24
Bound shell init publication acknowledgements
anthonykim1 Sep 1, 2026
30a4600
Make shell init sandbox grants conditional
anthonykim1 Sep 1, 2026
1b0d2a2
Test shell init file and activation updates
anthonykim1 Sep 1, 2026
10a6532
Clean up in-flight shell init writes on dispose
anthonykim1 Sep 1, 2026
8122749
Clear stale shell init sandbox grants
anthonykim1 Sep 1, 2026
d4e434b
Clean up failed shell init writes
anthonykim1 Sep 1, 2026
133c07d
Finish failed shell init cleanup
anthonykim1 Sep 1, 2026
62ecf31
Preserve registered shell init revisions
anthonykim1 Sep 1, 2026
d46f5ff
Document local shell init support
anthonykim1 Sep 1, 2026
595b59c
Test shell init timeout and PowerShell profiles
anthonykim1 Sep 1, 2026
596e0d3
Fix shell init tests on Windows
anthonykim1 Sep 2, 2026
bad4391
Preserve shell init in Agents settings saves
anthonykim1 Sep 2, 2026
5018440
Retain shell init revisions until disconnect
anthonykim1 Sep 2, 2026
010c113
Fail closed on shell init preflight errors
anthonykim1 Sep 2, 2026
a96908a
Bound shell init publication to one deadline
anthonykim1 Sep 2, 2026
b4a4572
Reduce Agent Host shell init to the core integration
anthonykim1 Sep 2, 2026
4536dc3
Guard shell init cleanup when a session never initialized
anthonykim1 Sep 2, 2026
75dc467
Shorten the Agent Host shell init setting description
anthonykim1 Sep 2, 2026
0e302a6
Enforce the shell init setting on the host and close the inherited co…
anthonykim1 Sep 2, 2026
978f876
Decide the shell init off state before validating the payload
anthonykim1 Sep 2, 2026
69068f2
Chain shell init cleanup only for sessions that wrote a script
anthonykim1 Sep 2, 2026
6d26ed2
Describe the forwarded shell init flag as opt-in, not authorization
anthonykim1 Sep 2, 2026
9d05bbc
Merge upstream main into anthonykim1/initScriptSDK
anthonykim1 Sep 2, 2026
aee0d7a
Rename the shell init session config key to shellInitScripts
anthonykim1 Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/vs/platform/agentHost/common/agentHostSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { IMcpServerConfiguration } from '../../mcp/common/mcpPlatformTypes.
import { TelemetryConfiguration, TelemetryLevel } from '../../telemetry/common/telemetry.js';
import { telemetryLevelToAgentHostValue } from './agentHostTelemetry.js';
import { SessionConfigKey } from './sessionConfigKeys.js';
import type { IShellInitScript } from './shellInitScript.js';
import type { SessionConfigPropertySchema, SessionConfigSchema } from './state/protocol/commands.js';
import { JsonRpcErrorCodes, ProtocolError } from './state/sessionProtocol.js';

Expand Down Expand Up @@ -299,6 +300,41 @@ const permissionsProperty = schemaProperty<IPermissionsValue>({
sessionMutable: true,
});

/**
* Scripts the client generated for this session, sourced before every built-in
* shell tool command (see `common/shellInitScript.ts`). Written by the
* workbench and consumed by the Copilot provider; `readOnly` because no user
* edits it directly, `sessionMutable` because the selected Python environment
* can change while a session is live. The value is transient and omitted from
* persisted session config.
*
* Deliberately has no `default`: an absent value means "nothing to apply",
* which must stay distinguishable from an explicit empty array (clear).
*/
const shellInitSnippetsProperty = schemaProperty<readonly IShellInitScript[]>({
type: 'array',
title: localize('agentHost.sessionConfig.shellInitSnippets', "Shell Init Script"),
description: localize('agentHost.sessionConfig.shellInitSnippetsDescription', "A script sourced before each built-in shell tool command."),
items: {
type: 'object',
title: localize('agentHost.sessionConfig.shellInitSnippets.item', "Shell Init Script"),
properties: {
shell: {
type: 'string',
title: localize('agentHost.sessionConfig.shellInitSnippets.shell', "Shell"),
enum: ['bash', 'powershell'],
},
script: {
type: 'string',
title: localize('agentHost.sessionConfig.shellInitSnippets.script', "Script"),
},
},
required: ['shell', 'script'],
},
readOnly: true,
sessionMutable: true,
});

/**
* Session-config properties owned by the platform itself — i.e. consumed
* by the agent host rather than by any particular agent.
Expand Down Expand Up @@ -345,6 +381,7 @@ export const platformSessionSchema = createSchema({
default: 'interactive',
sessionMutable: true,
}),
[SessionConfigKey.ShellInitSnippets]: shellInitSnippetsProperty,
});

/**
Expand Down
3 changes: 3 additions & 0 deletions src/vs/platform/agentHost/common/copilotCliConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ export const CopilotCliVSCodeAssignmentContextKey = 'copilotCliVSCodeAssignmentC

export const AgentHostCustomTerminalToolEnabledSettingId = 'chat.agentHost.customTerminalTool.enabled';

/** Enable VS Code's generated init script for the SDK built-in shell tool. */
export const AgentHostShellToolInitScriptEnabledSettingId = 'chat.agentHost.shellTool.initScript.enabled';

export const AgentHostCopilotSdkLogLevelSettingId = 'chat.agentHost.copilotSdk.logLevel';

export const AgentHostOpus48PromptEnabledSettingId = 'chat.agentHost.opus48Prompt.enabled';
Expand Down
12 changes: 12 additions & 0 deletions src/vs/platform/agentHost/common/sessionConfigKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export const enum SessionConfigKey {
AgentMerge = 'agentMerge',
/** `'agentMerge.controller'` — host-owned Agent Merge lifecycle state. */
AgentMergeController = 'agentMerge.controller',
/** `'shellInitSnippets'` — host-generated scripts sourced before built-in shell tool commands. */
ShellInitSnippets = 'shellInitSnippets',
}

/**
Expand All @@ -58,3 +60,13 @@ export const KNOWN_AUTO_APPROVE_VALUES: ReadonlySet<string> = new Set(['default'
* property: the agent execution mode axis.
*/
export const KNOWN_MODE_VALUES: ReadonlySet<string> = new Set(['interactive', 'plan', 'autopilot']);

/**
* Removes session config that is derived from live client state and must not
* survive an Agent Host restart.
*/
export function omitTransientSessionConfigValues<T>(values: Record<string, T>): Record<string, T> {
const result = { ...values };
delete result[SessionConfigKey.ShellInitSnippets];
return result;
}
142 changes: 142 additions & 0 deletions src/vs/platform/agentHost/common/shellInitScript.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { encodeBase64, VSBuffer } from '../../../base/common/buffer.js';
import { AgentHostClientType } from './agentHostClientInfo.js';
import { SessionConfigKey } from './sessionConfigKeys.js';

export type ShellInitScriptShell = 'bash' | 'powershell';

/**
* One host-generated script sourced before every SDK built-in shell command.
* The array-valued session config carries either no script (`[]`) or this one
* script, so clearing a previously applied script is explicit.
*/
export interface IShellInitScript {
readonly shell: ShellInitScriptShell;
readonly script: string;
}

export const SHELL_INIT_SCRIPT_EDITOR_ONLY_ERROR = 'Shell init script config can only be set by an Editor Window client.';

/**
* Returns an authorization error for executable shell-init config.
* Any client may clear the capability with `[]`; only Editor Windows may add
* executable text.
*/
export function getShellInitScriptConfigWriteError(config: Record<string, unknown>, clientType: AgentHostClientType): string | undefined {
if (!Object.hasOwn(config, SessionConfigKey.ShellInitSnippets)) {
return undefined;
}
const value = config[SessionConfigKey.ShellInitSnippets];
if (Array.isArray(value) && value.length === 0) {
return undefined;
}
return clientType === AgentHostClientType.EditorWindow ? undefined : SHELL_INIT_SCRIPT_EDITOR_ONLY_ERROR;
}

function quoteBash(value: string): string {
return `'${value.replaceAll(`'`, `'\\''`)}'`;
}

function encodedPowerShellExpression(value: string): string {
const encoded = encodeBase64(VSBuffer.fromString(value));
return `[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${encoded}'))`;
}

function powerShellBlock(body: readonly string[], failureMessage: string): string[] {
return [
`$__vscodePreviousErrorActionPreference = $ErrorActionPreference`,
`try {`,
`\t$ErrorActionPreference = 'Stop'`,
...body,
`} catch {`,
`\tWrite-Output '${failureMessage.replaceAll(`'`, `''`)}'`,
`} finally {`,
`\t$ErrorActionPreference = $__vscodePreviousErrorActionPreference`,
`}`,
];
}

/**
* Creates the single script VS Code registers with the SDK shell tool.
*
* Profile loading comes first so activation runs against the user's shell
* setup. Activation is whatever command the Python Environments extension
* published for the folder; nothing tool-specific is added here.
*
* Every script ends successfully: the runtime reports a nonzero init-script
* status before every later command, and discards Bash init-script stderr.
*/
export function createShellInitScript(shell: ShellInitScriptShell, pythonActivation: string | undefined): IShellInitScript {
return shell === 'powershell'
? createPowerShellInitScript(pythonActivation)
: createBashInitScript(pythonActivation);
}

function createBashInitScript(pythonActivation: string | undefined): IShellInitScript {
const lines = [
`# Generated by VS Code for Agent Host shell commands.`,
`if [ -r "$HOME/.bashrc" ]; then`,
// An rc file's status is the status of its final command. A nonzero
// status therefore does not mean profile setup failed.
`\tbuiltin source "$HOME/.bashrc" || builtin true`,
`fi`,
];
if (pythonActivation?.trim()) {
lines.push(
`if ! builtin eval ${quoteBash(pythonActivation)}; then`,
`\tprintf '%s\\n' 'copilot shell init: Python activation failed; continuing without the selected environment.'`,
`fi`,
);
}
lines.push(`builtin true`, ``);
return { shell: 'bash', script: lines.join('\n') };
}

function createPowerShellInitScript(pythonActivation: string | undefined): IShellInitScript {
// Profiles load with their normal 'Continue' preference — the runtime
// sources init scripts under 'Stop', which would let one benign
// non-terminating profile error skip everything after it. Each profile has
// its own try/catch so a broken profile does not skip the next one, and a
// preference a profile sets deliberately carries forward, as in a real
// session. The activation block below manages its own preference.
const lines = [
`# Generated by VS Code for Agent Host shell commands.`,
`$ErrorActionPreference = 'Continue'`,
`foreach ($__vscodeProfile in @($PROFILE.CurrentUserAllHosts, $PROFILE.CurrentUserCurrentHost)) {`,
`\ttry {`,
`\t\tif ($__vscodeProfile -and (Test-Path -LiteralPath $__vscodeProfile)) {`,
`\t\t\t. $__vscodeProfile`,
`\t\t}`,
`\t} catch {`,
`\t\tWrite-Output 'copilot shell init: loading the PowerShell profile failed; continuing.'`,
`\t}`,
`}`,
];
if (pythonActivation?.trim()) {
lines.push(...powerShellBlock(
[`\tInvoke-Expression (${encodedPowerShellExpression(pythonActivation)})`],
'copilot shell init: Python activation failed; continuing without the selected environment.',
));
}
lines.push(`$global:LASTEXITCODE = 0`, ``);
return { shell: 'powershell', script: lines.join('\n') };
}

/** Validates the client-pushed list before the agent host writes it to disk. */
export function isShellInitScriptList(value: unknown): value is readonly IShellInitScript[] {
return Array.isArray(value)
&& value.length <= 1
&& value.every(entry => {
if (!entry || typeof entry !== 'object') {
return false;
}
const script = entry as Partial<IShellInitScript>;
return (script.shell === 'bash' || script.shell === 'powershell')
&& typeof script.script === 'string'
&& script.script.length > 0;
});
}
40 changes: 34 additions & 6 deletions src/vs/platform/agentHost/node/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { AgentChatMigrationDeferred, AgentProvider, AgentSession, AgentSignal, I
import { type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentService } from '../common/agentService.js';
import { ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js';
import { IAgentEditAttributionService, ICancelEditAttributionFlushParams, ICommitEditAttributionFlushParams, IEditAttributionFlushResult, IPrepareEditAttributionFlushParams, IPreparedEditAttributionFlush, parseEditAttributionResource } from '../common/fileEditAttribution.js';
import { SessionConfigKey } from '../common/sessionConfigKeys.js';
import { omitTransientSessionConfigValues, SessionConfigKey } from '../common/sessionConfigKeys.js';
import type { IAgentCustomizationSettingsRegistration } from '../common/agentCustomizationSettings.js';
import { buildAnnotationsUri, parseAnnotationsUri } from '../common/annotationsUri.js';
import { AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY, isAgentHostAutomationMigrationCompletion } from '../common/automationMigration.js';
Expand Down Expand Up @@ -81,6 +81,7 @@ import { ICopilotApiService } from './shared/copilotApiService.js';
import { INetworkDiagnosticsService } from './networkDiagnosticsService.js';
import { toAgentClientUri } from '../common/agentClientUri.js';
import { AgentHostClientType } from '../common/agentHostClientInfo.js';
import { getShellInitScriptConfigWriteError } from '../common/shellInitScript.js';
import { resolveLastNonLocalTurnId } from '../common/agentHostConversationContext.js';
import { AgentHostLaunchKind, createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js';
import { IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js';
Expand Down Expand Up @@ -2973,9 +2974,12 @@ export class AgentService extends Disposable implements IAgentService {
this._syncAgentMergeIndex(session, undefined, sessionConfig);
this._serverToolHost.advertise(session.toString());
// Persist resolved config values for restore. Mid-session updates are
// persisted by `AgentSideEffects` on `SessionConfigChanged`.
// persisted by `SessionFlagsContribution` on `SessionConfigChanged`.
if (sessionConfig?.values && Object.keys(sessionConfig.values).length > 0 && !created.provisional) {
this._persistConfigValues(session, sessionConfig.values);
const persistedConfigValues = omitTransientSessionConfigValues(sessionConfig.values);
if (Object.keys(persistedConfigValues).length > 0) {
this._persistConfigValues(session, persistedConfigValues);
}
}

this._changesetCoordinator.onSessionCreated(session.toString());
Expand Down Expand Up @@ -3705,7 +3709,10 @@ export class AgentService extends Disposable implements IAgentService {
};
const configValues = state.config?.values;
if (configValues && Object.keys(configValues).length > 0) {
this._persistConfigValues(session, configValues);
const persistedConfigValues = omitTransientSessionConfigValues(configValues);
if (Object.keys(persistedConfigValues).length > 0) {
this._persistConfigValues(session, persistedConfigValues);
}
}
// Persist the AH-owned workspace-less marker now that the session has a
// real on-disk database (deferred from create for provisional sessions).
Expand Down Expand Up @@ -4508,6 +4515,10 @@ export class AgentService extends Disposable implements IAgentService {
? createUnknownAgentHostClientTelemetryContext(clientContextOrType)
: clientContextOrType;
this._logService.trace(`[AgentService] dispatchAction: type=${action.type}, clientId=${clientId}, clientSeq=${clientSeq}`, action);
if (action.type === ActionType.RootConfigChanged && Object.hasOwn(action.config, SessionConfigKey.ShellInitSnippets)) {
this._stateManager.rejectClientAction(channel, action, { clientId, clientSeq }, 'Shell init script config is session-only and cannot be set in root config.');
return;
}
if (action.type === ActionType.RootConfigChanged && Object.hasOwn(action.config, AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY)) {
const migration = action.config[AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY];
const origin = { clientId, clientSeq };
Expand Down Expand Up @@ -4757,12 +4768,29 @@ export class AgentService extends Disposable implements IAgentService {
// watermark, attempt budgets), so a client must never be able to write it, and
// a wholesale replacement must not drop it either.
if (action.type === ActionType.SessionConfigChanged) {
const configAction = action as SessionConfigChangedAction;
let configAction = action as SessionConfigChangedAction;
const forbidden = HOST_WRITTEN_SESSION_CONFIG_KEYS.filter(key => Object.hasOwn(configAction.config, key));
if (forbidden.length > 0) {
this._stateManager.rejectClientAction(channel, action, origin, `Session config keys are host-owned and cannot be set by a client: ${forbidden.join(', ')}.`);
return;
}
const editorClient = clientContext.clientType === AgentHostClientType.EditorWindow;
const writesShellInit = Object.hasOwn(configAction.config, SessionConfigKey.ShellInitSnippets);
const shellInitWriteError = getShellInitScriptConfigWriteError(configAction.config, clientContext.clientType);
if (shellInitWriteError) {
this._stateManager.rejectClientAction(channel, action, origin, shellInitWriteError);
return;
}
if (!editorClient && configAction.replace && !writesShellInit) {
const existingShellInit = this._stateManager.getSessionState(sessionChannel)?.config?.values[SessionConfigKey.ShellInitSnippets];
if (existingShellInit !== undefined) {
configAction = {
...configAction,
config: { ...configAction.config, [SessionConfigKey.ShellInitSnippets]: existingShellInit },
};
action = configAction;
}
}
if (configAction.replace) {
action = this._withPreservedHostWrittenSessionConfig(sessionChannel, configAction);
}
Expand Down Expand Up @@ -5562,7 +5590,7 @@ export class AgentService extends Disposable implements IAgentService {

if (m.configValues) {
try {
persistedConfigValues = JSON.parse(m.configValues);
persistedConfigValues = omitTransientSessionConfigValues(JSON.parse(m.configValues));
} catch (err) {
this._logService.warn(`[AgentService] Failed to parse persisted configValues for ${sessionStr}: ${toErrorMessage(err)}`);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { IAgentHostChatContribution, IAgentHostChatContributionContext, IDi
import { ISessionDataService } from '../../../common/sessionDataService.js';
import { ActionType } from '../../../common/state/sessionActions.js';
import { AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_READ_DB_KEY } from '../../../common/state/sessionState.js';
import { omitTransientSessionConfigValues } from '../../../common/sessionConfigKeys.js';
import { AgentHostStateManager, IAgentHostStateManager } from '../../agentHostStateManager.js';
import { persistSessionMetadata } from '../../shared/persistSessionMetadata.js';

Expand All @@ -35,7 +36,7 @@ export class SessionFlagsContribution extends Disposable implements IAgentHostCh
if (dispatched.action.type === ActionType.SessionConfigChanged) {
const values = this._stateManager.getSessionState(dispatched.channel)?.config?.values;
if (values) {
persistSessionMetadata(this._sessionDataService, this._logService, dispatched.channel, 'configValues', JSON.stringify(values));
persistSessionMetadata(this._sessionDataService, this._logService, dispatched.channel, 'configValues', JSON.stringify(omitTransientSessionConfigValues(values)));
}
}
// Persisting here rather than in `handleAction` covers client- and
Expand Down
Loading
Loading