Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
40 changes: 40 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 { IShellInitSnippet } from './shellInitSnippets.js';
import type { SessionConfigPropertySchema, SessionConfigSchema } from './state/protocol/commands.js';
import { JsonRpcErrorCodes, ProtocolError } from './state/sessionProtocol.js';

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

/**
* Scripts the client generated for this session, sourced before every built-in
* shell tool command (see `common/shellInitSnippets.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.
*
* 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 IShellInitSnippet[]>({
type: 'array',
title: localize('agentHost.sessionConfig.shellInitSnippets', "Shell Init Scripts"),
description: localize('agentHost.sessionConfig.shellInitSnippetsDescription', "Scripts sourced before each built-in shell tool command, such as Python environment activation."),
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"),
},
source: {
type: 'string',
title: localize('agentHost.sessionConfig.shellInitSnippets.source', "Source"),
},
},
required: ['shell', 'script', 'source'],
},
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 +384,7 @@ export const platformSessionSchema = createSchema({
default: 'interactive',
sessionMutable: true,
}),
[SessionConfigKey.ShellInitSnippets]: shellInitSnippetsProperty,
});

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

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

/** Activate the selected Python environment in the SDK built-in shell tool. */
export const AgentHostShellToolPythonActivationSettingId = 'chat.agentHost.shellTool.pythonActivation';

/** Load the user's shell profile in the SDK built-in shell tool. */
export const AgentHostShellToolLoadUserProfileSettingId = 'chat.agentHost.shellTool.loadUserProfile';

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

export const AgentHostOpus48PromptEnabledSettingId = 'chat.agentHost.opus48Prompt.enabled';
Expand Down
2 changes: 2 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 Down
266 changes: 266 additions & 0 deletions src/vs/platform/agentHost/common/shellInitSnippets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

/**
* Generators for the shell init scripts sourced before every built-in shell
* tool command (SDK `ShellOptions.initScripts`).
*
* The SDK shell tool spawns a fresh, no-rc shell per command
* (`bash --norc --noprofile -c` / `pwsh -NoProfile -NoLogo -NonInteractive
* -Command`), so nothing the user's interactive shell would set up is present.
* The workbench resolves what a session needs, generates the script *text*
* here, and pushes it as session config; the agent host materializes the text
* to disk and registers the paths with the SDK.
*
* Three runtime behaviors constrain every script produced here:
*
* 1. Only the script's **final** exit status is observed. A nonzero status makes
* the runtime print a failure notice before *every* subsequent command, so
* each script must end with a statement that always succeeds.
* 2. Bash init-script **stderr is discarded**; diagnostics must go to stdout.
* 3. Scripts are sourced *into* the command shell, so `exit`, `exec`, or a
* failure under `set -e` inside sourced content can still kill the command.
* That is unavoidable for user-authored content and is documented for the
* profile snippet.
*/

/** A shell targeted by {@link IShellInitSnippet}; mirrors the SDK's `ShellInitScriptShell`. */
export type ShellInitSnippetShell = 'bash' | 'powershell';

/**
* One host-generated init script, carried as text so the producer never needs
* filesystem access and the agent host stays in control of where files land.
*/
export interface IShellInitSnippet {
/** Built-in shell this snippet applies to. */
readonly shell: ShellInitSnippetShell;
/** Script contents, sourced before each built-in shell command. */
readonly script: string;
/** Provenance, used for the generated filename and for logging. */
readonly source: string;
}

/** Ordering prefix for the generated files; profile setup must precede activation. */
export const enum ShellInitSnippetSource {
/** Loads the user's shell profile / rc so hooks and shell functions exist. */
UserProfile = 'user-profile',
/** Activates the selected Python environment. */
PythonEnv = 'python-env',
}

/**
* Marker-delimited region replayed from a bash rc file when sourcing it did not
* take effect.
*
* Stock rc files on Debian/Ubuntu/Fedora begin with an interactivity guard
* (`case $- in *i*) ;; *) return;; esac`), and tools such as conda *append*
* their init block below it. Sourcing such a file from a non-interactive shell
* therefore returns before the block is reached. Replaying just the marked
* region recovers the shell functions those blocks define.
*
* `probe` keeps the replay idempotent: it runs only when the region has not
* already taken effect.
*/
interface IShellInitRcRegion {
/** Exact rc line that opens the region. */
readonly startMarker: string;
/** Exact rc line that closes the region. */
readonly endMarker: string;
/** Command name whose absence means the region has not taken effect. */
readonly probe: string;
}

/**
* Regions replayed from bash rc files. Conda is the only shipped entry: it is
* the dominant hook-based Python environment manager, and `conda activate` is a
* shell *function*, so it cannot be recovered from inherited environment
* variables the way `PATH`-based setups can.
*/
const BASH_RC_REGIONS: readonly IShellInitRcRegion[] = [
{
startMarker: '# >>> conda initialize >>>',
endMarker: '# <<< conda initialize <<<',
probe: 'conda',
},
];

/** Quotes a value for use inside a single-quoted POSIX shell string. */
function toBashSingleQuoted(value: string): string {
return `'${value.replaceAll(`'`, `'\\''`)}'`;
}

/**
* Wraps `body` in a PowerShell non-interpolating here-string.
*
* Throws when the payload contains a line that would terminate the here-string
* early, which is the only sequence `@'...'@` cannot represent.
*/
function toPowerShellHereString(value: string): string {
if (value.split(/\r?\n/).some(line => line.trimEnd() === `'@`)) {
throw new Error(`Cannot embed a PowerShell here-string payload containing a line equal to "'@"`);
}
return `@'\n${value}\n'@`;
}

/** Escapes a value for a single-quoted PowerShell string. */
function toPowerShellSingleQuoted(value: string): string {
return `'${value.replaceAll(`'`, `''`)}'`;
}

/**
* Wraps generated PowerShell so a failure is reported without aborting the
* remaining scripts or the user's command.
*
* The runtime dot-sources init scripts under `$ErrorActionPreference = 'Stop'`,
* so the preference is saved and restored around the body. The trailing
* `$global:LASTEXITCODE = 0` guarantees a successful final status, clearing any
* nonzero native exit code the body may have left behind.
*/
function wrapPowerShell(header: string, body: string, failureMessage: string): string {
return [
`# ${header}`,
`$__vscodePreviousErrorActionPreference = $ErrorActionPreference`,
`try {`,
`\t$ErrorActionPreference = 'Stop'`,
body,
`} catch {`,
`\tWrite-Output ${toPowerShellSingleQuoted(failureMessage)}`,
`} finally {`,
`\t$ErrorActionPreference = $__vscodePreviousErrorActionPreference`,
`}`,
`$global:LASTEXITCODE = 0`,
``,
].join('\n');
}

/**
* Builds the Python activation snippets for a session.
*
* `activation` is the ready-to-eval command string published by the Python
* environment extension (for example ` source /repo/.venv/bin/activate`). It is
* embedded by value: the SDK has no per-session shell `env`, so the variable it
* normally arrives in cannot be read from inside the shell tool.
*
* Returns an empty array for absent or blank input so callers can pass the
* result straight through to clear any previously applied scripts.
*/
export function createPythonActivationSnippets(shell: ShellInitSnippetShell, activation: string | undefined): IShellInitSnippet[] {
if (!activation?.trim()) {
return [];
}
const failureMessage = 'copilot shell init: Python activation failed; continuing without the selected environment.';
const header = 'Generated by VS Code from the selected Python environment.';
if (shell === 'powershell') {
return [{
shell,
source: ShellInitSnippetSource.PythonEnv,
script: wrapPowerShell(header, `\tInvoke-Expression ${toPowerShellHereString(activation)}`, failureMessage),
}];
}
return [{
shell,
source: ShellInitSnippetSource.PythonEnv,
script: [
`# ${header}`,
`if ! builtin eval ${toBashSingleQuoted(activation)}; then`,
`\tprintf '%s\\n' ${toBashSingleQuoted(failureMessage)}`,
`fi`,
`builtin true`,
``,
].join('\n'),
}];
}

/** Emits the guarded replay of one marker-delimited rc region. */
function bashRcRegionReplay(region: IShellInitRcRegion): string[] {
const variable = `__vscode_rc_region`;
// `sed` reads the rc file fresh on every command, matching the runtime's
// per-command re-sourcing, so no rc content is ever captured at generation
// time and allowed to go stale.
const extract = `sed -n ${toBashSingleQuoted(`/^${region.startMarker}$/,/^${region.endMarker}$/p`)} "$HOME/.bashrc" 2>/dev/null`;
return [
`\tif ! builtin type ${region.probe} >/dev/null 2>&1; then`,
Comment thread
anthonykim1 marked this conversation as resolved.
Outdated
`\t\t${variable}="$(${extract})"`,
`\t\tif [ -n "$${variable}" ] && ! builtin eval "$${variable}"; then`,
`\t\t\tprintf '%s\\n' ${toBashSingleQuoted(`copilot shell init: ${region.probe} initialization failed; continuing.`)}`,
`\t\tfi`,
`\t\tbuiltin unset ${variable}`,
`\tfi`,
];
}

/**
* Builds the snippets that load the user's shell profile, so hooks and shell
* *functions* — which are never exported and therefore cannot arrive through
* inherited environment variables — exist in the command shell.
*
* Bash sources `~/.bashrc` best-effort and then replays any
* {@link BASH_RC_REGIONS} that did not take effect, which is what makes this
* work despite the interactivity guard in stock rc files.
*
* `~/.zshrc` is deliberately never sourced: it is zsh syntax and the tool shell
* is always bash. PowerShell dot-sources the current user's profiles, which
* have no interactivity-guard convention and so need no replay.
*/
export function createUserProfileSnippets(shell: ShellInitSnippetShell): IShellInitSnippet[] {
if (shell === 'powershell') {
const body = [
`\tforeach ($__vscodeProfile in @($PROFILE.CurrentUserAllHosts, $PROFILE.CurrentUserCurrentHost)) {`,
`\t\tif ($__vscodeProfile -and (Test-Path -LiteralPath $__vscodeProfile)) {`,
`\t\t\t. $__vscodeProfile`,
`\t\t}`,
`\t}`,
].join('\n');
return [{
shell,
source: ShellInitSnippetSource.UserProfile,
script: wrapPowerShell(
`Generated by VS Code: load the user's PowerShell profile for agent shell commands.`,
body,
'copilot shell init: loading the PowerShell profile failed; continuing.',
),
}];
}
return [{
shell,
source: ShellInitSnippetSource.UserProfile,
script: [
`# Generated by VS Code: load the user's bash rc for agent shell commands.`,
`# Stock rc files return early in non-interactive shells, so after the`,
`# best-effort source we replay managed blocks that did not take effect.`,
`if [ -r "$HOME/.bashrc" ]; then`,
`\tif ! builtin source "$HOME/.bashrc"; then`,
`\t\tprintf '%s\\n' ${toBashSingleQuoted('copilot shell init: loading ~/.bashrc failed; continuing.')}`,
`\tfi`,
...BASH_RC_REGIONS.flatMap(bashRcRegionReplay),
`fi`,
`builtin true`,
``,
].join('\n'),
}];
}

/** Restricts a snippet source to the characters used in generated filenames. */
export function sanitizeSnippetSource(value: string): string {
return value.replace(/[^a-zA-Z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').substring(0, 64);
}

/**
* Validates a value received over the protocol before it is written to disk.
* The agent host must not trust the shape of client-pushed session config.
*/
export function isShellInitSnippetArray(value: unknown): value is IShellInitSnippet[] {
return Array.isArray(value) && value.every(entry => {
if (!entry || typeof entry !== 'object') {
return false;
}
const candidate = entry as Partial<IShellInitSnippet>;
return (candidate.shell === 'bash' || candidate.shell === 'powershell')
&& typeof candidate.script === 'string'
&& candidate.script.length > 0
&& typeof candidate.source === 'string'
&& sanitizeSnippetSource(candidate.source).length > 0;
});
}
5 changes: 5 additions & 0 deletions src/vs/platform/agentHost/node/copilot/copilotAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitP
import { parsedPluginsEqual, toChildCustomizations } from './copilotPluginConverters.js';
import { CopilotGitHubTelemetryForwarder } from './copilotGitHubTelemetryForwarder.js';
import { CopilotSessionLauncher, ContextSizeConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, isCopilotReasoningEffort, resolveCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js';
import { ShellInitScriptMaterializer } from './shellInitScriptMaterializer.js';
import { CopilotAgentStartupConfig } from './copilotAgentStartupConfig.js';
import { ShellManager } from './copilotShellTools.js';
import { isAgentHostTelemetryService } from '../agentHostTelemetryService.js';
Expand Down Expand Up @@ -874,6 +875,7 @@ export class CopilotAgent extends Disposable implements IAgent {
private _isShuttingDown = false;
private readonly _plugins: PluginController;
private readonly _sessionLauncher: CopilotSessionLauncher;
private readonly _shellInitScriptMaterializer: ShellInitScriptMaterializer;
private readonly _gitHubTelemetryForwarder: CopilotGitHubTelemetryForwarder;
private _vscodeAssignmentContext: string | undefined;
private readonly _githubTelemetryRouter: AgentHostGitHubTelemetryRouter | undefined;
Expand Down Expand Up @@ -915,6 +917,7 @@ export class CopilotAgent extends Disposable implements IAgent {
this._lastStartupConfig = this._readClientStartupConfig();
this._plugins = this._register(this._instantiationService.createInstance(PluginController, () => this._ensureClient()));
this._sessionLauncher = this._instantiationService.createInstance(CopilotSessionLauncher);
this._shellInitScriptMaterializer = this._instantiationService.createInstance(ShellInitScriptMaterializer);
this._configurationService.publishRootTransientValues?.({ [CopilotCliVSCodeAssignmentContextKey]: undefined });
this._gitHubTelemetryForwarder = this._instantiationService.createInstance(CopilotGitHubTelemetryForwarder, () => this._restrictedTelemetryEnabled, () => this._vscodeAssignmentContext);
this._register(this._configurationService.onDidRootConfigChange(() => this._updateVSCodeAssignmentContext()));
Expand Down Expand Up @@ -3879,6 +3882,7 @@ export class CopilotAgent extends Disposable implements IAgent {
if (isWorkspaceless) {
await this._cleanupWorkspacelessScratchDir(this._workspacelessScratchDir(scopeId), scopeId);
}
await this._shellInitScriptMaterializer.clear(scopeId);
Comment thread
anthonykim1 marked this conversation as resolved.
Outdated
this._otelService.releaseSessionTraceContext(scope.toString());
await this._applyPendingClientRestart();
}
Expand Down Expand Up @@ -4727,6 +4731,7 @@ export class CopilotAgent extends Disposable implements IAgent {
rawSessionId: launchPlan.sessionId,
onDidSessionProgress: this._onDidChatProgress,
sessionLauncher: this._sessionLauncher,
shellInitScriptMaterializer: this._shellInitScriptMaterializer,
launchPlan,
shellManager: launchPlan.shellManager,
workingDirectory: launchPlan.workingDirectory,
Expand Down
Loading
Loading