Skip to content

Commit 3d6f125

Browse files
anthonykim1Copilot
andauthored
Activate Python environments in Agent Host shell commands with shell init scripts (#332593)
* Add shell init snippet generators for the SDK shell tool The SDK built-in shell tool spawns a fresh no-rc shell per command, so the user's Python environment and shell hooks are absent. Add the generators that produce the init script text later sourced via ShellOptions.initScripts. Three runtime behaviors shape the generated text: only the final exit status is observed (so every script ends in a statement that always succeeds), bash init-script stderr is discarded (so diagnostics go to stdout), and PowerShell scripts are dot-sourced under $ErrorActionPreference = 'Stop' (so the body is wrapped in try/catch/finally). The profile snippet sources ~/.bashrc and then replays marker-delimited managed blocks that did not take effect. Stock rc files early-return in non-interactive shells and tools such as conda append their init block below that guard, so sourcing alone never reaches it. Replaying recovers shell functions such as conda activate, which are never exported and so cannot arrive through the inherited environment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add the shellInitSnippets session config key Carries generated shell init script text from the client to the agent host as per-session config. Values are pushed rather than user-authored, so the property is readOnly; the selected Python environment can change while a session is live, so it is sessionMutable. The property deliberately has no default and is not added to the defaults passed to validateOrDefault: an absent value means "nothing to apply", which must stay distinguishable from an explicit empty array, which clears previously applied scripts. readOnly keeps the property out of the session settings file but does not suppress the generic chat-input chip, so the key is also added to WELL_KNOWN_PICKER_PROPERTIES. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Let buildSandboxConfigForSdk grant extra readonly paths Host-generated files that the SDK shell tool must read, such as a session's shell init scripts, live under the user data path, which no existing grant covers. Add an optional extraReadonlyPaths parameter routed through the same precedence sets as user-supplied paths, so an explicit denyRead still wins and a path already granted readwrite is not downgraded. The SDK treats init script readability as a caller obligation and fails silently when a script cannot be read, so the doc comment records that every producer of a session sandboxConfig must pass the same paths. Call sites are updated in a later change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Apply shell init scripts to SDK built-in shell tool sessions Materializes the shell init snippets configured on a session and registers their paths through options.update, so the SDK sources them before every built-in shell tool command. Both producers of a session sandbox policy now grant read access to the session's script directory. CopilotAgentSession re-pushes sandboxConfig on every turn, so granting it only at launch would silently revoke the grant on the first turn, and the SDK reports nothing when it cannot read an init script. The apply lives on the session rather than the launcher so it can re-run on a mid-session config change and on a cold resume: the runtime does not persist its init-script list, but the host's session config values are persisted. Paths are derived from the snippet shape, so an unchanged list skips the RPC and changed content takes effect through the rewritten file, which the runtime re-reads before each command. Writes are atomic where the provider supports it so a command running during a rewrite cannot source a half-written file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Publish Python activation and profile scripts for agent shell commands Adds the workbench half: a synchronizer that resolves what a session's shell tool needs and publishes it as script text on the session's config, which the agent host then materializes and registers with the SDK. Python activation is read from the environment-variable collection the Python Environments extension publishes, scoped to the workspace folder that owns the session. Only that extension may supply the value, since it becomes an executable script. On POSIX the zsh variable is accepted as a fallback for bash: the extension emits identical text for both, and the tool shell is always bash. Fish and cmd are never used because their syntax would not parse. Worktree-isolated sessions resolve through their originating project, since a worktree path is not a workspace folder and activation commands carry absolute paths. Config is dispatched only when the value actually changes, and an empty list is never published to a session that never had one. Session config is shared across windows, so an unconditional write would let two windows overwrite each other indefinitely. Both snippets are behind experimental settings, on by default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Reduce shell init integration to one script Collapse profile loading and Python activation into one generated init script behind one setting. Inline file ownership in CopilotAgentSession, remove the generic materializer/source/index machinery, and scope publication to local workspace windows. Also apply live config changes serially, verify conda is a shell function before skipping hook replay, and clean up the SDK-session-scoped file on session disposal. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address shell init review feedback Restrict publishing to the workspace window that owns the session and skip remote hosts whose OS can differ from the renderer. Serialize live script updates, require conda to be a shell function, and add missing handler test dependencies. Inline SDK-session-scoped file ownership so cleanup cannot use a host configuration ID, and format the schema test flagged by CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address latest shell init review Publish synchronously before the first turn, ensure the sandbox grants access before SDK registration, and serialize final cleanup behind pending script synchronization. Limit publication to the workspace window that owns the session and trim the shell-init contract JSDoc. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Make shell init test paths platform-neutral Build expected sandbox and init-script paths through URI.fsPath so the session tests pass on Windows as well as POSIX platforms. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Only the owning window clears shell init; scope script files per instance * Load pwsh profiles under Continue; prune empty shell init session dirs * Drop the conda-specific rc replay from the shell init script * Read only the shell-matching activation variable; add SDK shell init e2e * Default Agent Host shell init scripts off Keep the setting experimental and require an explicit true value before publishing profile/Python activation scripts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef9b9c0-98f2-4b26-8b8b-18119efe7b13 * Correct Agent Host shell profile sourcing Keep profile loading before Python activation, but do not infer a Bash profile failure from the status of its final command. Clarify the exact Bash and PowerShell profiles loaded and cover non-interactive guards and rc files ending nonzero. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef9b9c0-98f2-4b26-8b8b-18119efe7b13 * Keep Agent Host shell init state transient Exclude workspace-derived executable shell init text from session persistence, strip legacy persisted values during restore, and make the application-scoped disabled setting authoritative from any local window. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef9b9c0-98f2-4b26-8b8b-18119efe7b13 * Prevent shell init config update loops After session schema hydration, publish only from local inputs and explicit pre-turn reconciliation instead of reacting to shared config echoes. Add a two-window convergence regression test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef9b9c0-98f2-4b26-8b8b-18119efe7b13 * Encode PowerShell shell init payloads safely Embed arbitrary multiline activation text as UTF-8 base64 and decode it in PowerShell instead of using a terminable here-string. Add round-trip and Windows execution coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef9b9c0-98f2-4b26-8b8b-18119efe7b13 * Reject malformed shell init config safely Distinguish invalid shell init state from an explicit empty-list clear so malformed client or inherited config cannot remove the last valid SDK registration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef9b9c0-98f2-4b26-8b8b-18119efe7b13 * Restrict shell init config to editor clients Reject executable shell init config writes from non-editor clients and preserve the current value across their wholesale config replacements. Add acceptance, rejection, and replacement coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef9b9c0-98f2-4b26-8b8b-18119efe7b13 * Document shell init script approval behavior Explain that direct script commands may require explicit approval while the experimental init-script integration is enabled, without changing VS Code or runtime approval policy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef9b9c0-98f2-4b26-8b8b-18119efe7b13 * Cover first-turn shell init ordering Wait for the shell-init config echo before an Editor Window dispatches its first turn. Exercise the raw AHP path with an Editor Window identity, unique client sequences, and no explicit echo wait before the turn. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef9b9c0-98f2-4b26-8b8b-18119efe7b13 * Allow clients to clear shell init config Keep non-empty executable config restricted to Editor Window clients, while allowing any local client to submit the capability-reducing empty-list clear. Preserve the value when an unauthorized replacement merely omits it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef9b9c0-98f2-4b26-8b8b-18119efe7b13 * Apply shell init config before resumed turns Reconcile before both new and resumed turns, wait for the matching server echo, and fail preparation when the host rejects the config instead of proceeding with stale shell initialization. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef9b9c0-98f2-4b26-8b8b-18119efe7b13 * Run the PowerShell shell init test on Windows Move the Windows-only execution suite out of the non-Windows Bash suite so CI can exercise base64 decoding and activation in a real PowerShell process. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef9b9c0-98f2-4b26-8b8b-18119efe7b13 * Close shell init config authorization bypasses Reject the session-only executable config from root state and apply the shared Editor Window policy during session creation as well as later updates. Keep explicit empty-list clears available to all clients. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef9b9c0-98f2-4b26-8b8b-18119efe7b13 * Wait for pending shell init publication Track in-flight shell init actions and compare pre-turn state against the server-confirmed snapshot. Reconciliation now waits for the matching acknowledgement when the desired value is only optimistic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef9b9c0-98f2-4b26-8b8b-18119efe7b13 * Serialize shell init config publications Queue each publication through a per-session sequencer, retain its acknowledgement until completion, and make pre-turn reconciliation wait when the desired value is only optimistic or supersedes an earlier update. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef9b9c0-98f2-4b26-8b8b-18119efe7b13 * Bound shell init publication acknowledgements Prevent a lost connection echo from occupying the publication sequencer forever, and let cancellation release a pre-turn reconcile queued behind an unacknowledged background update. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef9b9c0-98f2-4b26-8b8b-18119efe7b13 * Make shell init sandbox grants conditional Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef9b9c0-98f2-4b26-8b8b-18119efe7b13 * Test shell init file and activation updates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef9b9c0-98f2-4b26-8b8b-18119efe7b13 * Clean up in-flight shell init writes on dispose Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef9b9c0-98f2-4b26-8b8b-18119efe7b13 * Clear stale shell init sandbox grants Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef9b9c0-98f2-4b26-8b8b-18119efe7b13 * Clean up failed shell init writes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef9b9c0-98f2-4b26-8b8b-18119efe7b13 * Finish failed shell init cleanup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef9b9c0-98f2-4b26-8b8b-18119efe7b13 * Preserve registered shell init revisions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef9b9c0-98f2-4b26-8b8b-18119efe7b13 * Document local shell init support Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef9b9c0-98f2-4b26-8b8b-18119efe7b13 * Test shell init timeout and PowerShell profiles Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef9b9c0-98f2-4b26-8b8b-18119efe7b13 * Fix shell init tests on Windows Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef9b9c0-98f2-4b26-8b8b-18119efe7b13 * Preserve shell init in Agents settings saves Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef9b9c0-98f2-4b26-8b8b-18119efe7b13 * Retain shell init revisions until disconnect Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef9b9c0-98f2-4b26-8b8b-18119efe7b13 * Fail closed on shell init preflight errors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef9b9c0-98f2-4b26-8b8b-18119efe7b13 * Bound shell init publication to one deadline Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef9b9c0-98f2-4b26-8b8b-18119efe7b13 * Reduce Agent Host shell init to the core integration Keep the generated script, the session config key, the workbench synchronizer, and the host-side materialize, grant, register, and cleanup path, and drop the layers that accumulated during review: - No renderer-side acknowledgement barrier. Dispatch is ordered per connection and the host applies session config before it starts a turn, so reconcile publishes synchronously again. - No client-type authorization for the config key. Connected clients can already run shell commands through the agent; readOnly plus transient state is the model. This also removes the Agents-window settings-save rejection and its carry-forward patch. - No script revisions. One file per instance is rewritten atomically in place and deleted only on dispose after the SDK session disconnects, so a command that already captured the path can still source it. - SDK registration failures are logged and retried on the next turn rather than failing the turn. The sandbox read grant is added once a script is materialized and kept for the instance lifetime. Windows test portability, echo suppression, transient persistence, and base64 PowerShell payloads are unchanged. * Guard shell init cleanup when a session never initialized dispose() called this._wrapper.disconnect() before the base disposal so the script file is removed only after the SDK session disconnects. The wrapper is assigned midway through initializeSession, so a session that fails during launch has none, the call throws a TypeError, and the base disposal never runs. That leaked the session and its ShellManager in the resume tests, and in the launcher's fallback path the TypeError replaced the SDK's model error, which broke the stale-model E2E on every platform. Skip the disconnect-then-cleanup chain when there is no wrapper; such a session has no script to remove either. * Shorten the Agent Host shell init setting description * Enforce the shell init setting on the host and close the inherited config path CopilotAgentSession read shellInitSnippets through the effective config chain, so a value placed in root config by any connected client was inherited by every Copilot session without its own value, and the workbench setting only governed which window published. Read the session's own value only, and apply it only while the forwarded enableShellInitScript root flag is true, so the user setting is enforced by the host regardless of who published the value. The flag is forwarded next to the other Copilot CLI settings and re-applied on root config changes, unregistering a live script when it turns off. Also: the Agents window mounts the active session's folder into its workspace, so folder ownership alone qualified it as a publisher; it now only clears. Cap accepted script text at 64 KiB. Remove the generated directory on dispose even when the SDK disconnect fails. Qualify the generator note that a sourced profile can still terminate the shell. The recorded E2E enables the flag through root config before the snapshotted round and leaves the root channel so its notifications stay out of the round. * Decide the shell init off state before validating the payload The malformed-payload check ran before the forwarded flag was read, so a registered script survived turning the setting off if the session value had become malformed in between. The custom terminal tool check returned early for the same reason, leaving a dormant SDK registration when the tool was switched on mid-session. Compute the off state first: the flag is false, or the custom terminal tool has replaced the SDK's built-in shell. Either clears any registration without looking at the payload; only an enabled session validates it. * Chain shell init cleanup only for sessions that wrote a script dispose() started the SDK disconnect explicitly for every Copilot session so the script file could be removed afterwards. Sessions that never materialized a script have nothing to remove, so they now keep the plain dispose path; the disposing flag is still set synchronously so no later sync can run. * Describe the forwarded shell init flag as opt-in, not authorization * Rename the shell init session config key to shellInitScripts The key was named for an early design that published separate profile and activation snippets. The value has held one whole script since those were collapsed, and everything around it already says script: IShellInitScript, createShellInitScript, isShellInitScriptList, enableShellInitScript, and the SDK's own shell.initScripts. Rename the key, the schema property, its localization ids, and the local variables to match. Also say the script is client-generated where two comments called it host-generated; the host only writes the file. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bef9b9c0-98f2-4b26-8b8b-18119efe7b13
1 parent fb4a02b commit 3d6f125

28 files changed

Lines changed: 1804 additions & 32 deletions

‎src/vs/platform/agentHost/common/agentHostSchema.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { IMcpServerConfiguration } from '../../mcp/common/mcpPlatformTypes.
1111
import { TelemetryConfiguration, TelemetryLevel } from '../../telemetry/common/telemetry.js';
1212
import { telemetryLevelToAgentHostValue } from './agentHostTelemetry.js';
1313
import { SessionConfigKey } from './sessionConfigKeys.js';
14+
import type { IShellInitScript } from './shellInitScript.js';
1415
import type { SessionConfigPropertySchema, SessionConfigSchema } from './state/protocol/commands.js';
1516
import { JsonRpcErrorCodes, ProtocolError } from './state/sessionProtocol.js';
1617

@@ -299,6 +300,41 @@ const permissionsProperty = schemaProperty<IPermissionsValue>({
299300
sessionMutable: true,
300301
});
301302

303+
/**
304+
* Scripts the client generated for this session, sourced before every built-in
305+
* shell tool command (see `common/shellInitScript.ts`). Written by the
306+
* workbench and consumed by the Copilot provider; `readOnly` because no user
307+
* edits it directly, `sessionMutable` because the selected Python environment
308+
* can change while a session is live. The value is transient and omitted from
309+
* persisted session config.
310+
*
311+
* Deliberately has no `default`: an absent value means "nothing to apply",
312+
* which must stay distinguishable from an explicit empty array (clear).
313+
*/
314+
const shellInitScriptsProperty = schemaProperty<readonly IShellInitScript[]>({
315+
type: 'array',
316+
title: localize('agentHost.sessionConfig.shellInitScripts', "Shell Init Script"),
317+
description: localize('agentHost.sessionConfig.shellInitScriptsDescription', "A script sourced before each built-in shell tool command."),
318+
items: {
319+
type: 'object',
320+
title: localize('agentHost.sessionConfig.shellInitScripts.item', "Shell Init Script"),
321+
properties: {
322+
shell: {
323+
type: 'string',
324+
title: localize('agentHost.sessionConfig.shellInitScripts.shell', "Shell"),
325+
enum: ['bash', 'powershell'],
326+
},
327+
script: {
328+
type: 'string',
329+
title: localize('agentHost.sessionConfig.shellInitScripts.script', "Script"),
330+
},
331+
},
332+
required: ['shell', 'script'],
333+
},
334+
readOnly: true,
335+
sessionMutable: true,
336+
});
337+
302338
/**
303339
* Session-config properties owned by the platform itself — i.e. consumed
304340
* by the agent host rather than by any particular agent.
@@ -345,6 +381,7 @@ export const platformSessionSchema = createSchema({
345381
default: 'interactive',
346382
sessionMutable: true,
347383
}),
384+
[SessionConfigKey.ShellInitScripts]: shellInitScriptsProperty,
348385
});
349386

350387
/**

‎src/vs/platform/agentHost/common/copilotCliConfig.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import { reasoningEffortLevels } from './reasoningEffort.js';
1616
export const enum CopilotCliConfigKey {
1717
/** Use Agent Host's custom terminal tool instead of the SDK's default. Off by default. */
1818
EnableCustomTerminalTool = 'enableCustomTerminalTool',
19+
/** Apply the shell init script a client published for a session to SDK shell commands. Off by default. */
20+
EnableShellInitScript = 'enableShellInitScript',
1921
/** Log level passed to the Copilot SDK client. */
2022
CopilotSdkLogLevel = 'copilotSdkLogLevel',
2123
/** Enable the rubber duck critic subagent. */
@@ -52,6 +54,9 @@ export const CopilotCliVSCodeAssignmentContextKey = 'copilotCliVSCodeAssignmentC
5254

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

57+
/** Enable VS Code's generated init script for the SDK built-in shell tool. */
58+
export const AgentHostShellToolInitScriptEnabledSettingId = 'chat.agentHost.shellTool.initScript.enabled';
59+
5560
export const AgentHostCopilotSdkLogLevelSettingId = 'chat.agentHost.copilotSdk.logLevel';
5661

5762
export const AgentHostOpus48PromptEnabledSettingId = 'chat.agentHost.opus48Prompt.enabled';
@@ -148,6 +153,12 @@ export const copilotCliConfigSchema = createSchema({
148153
description: localize('agentHost.config.enableCustomTerminalTool.description', "When enabled, Copilot SDK sessions use Agent Host's terminal tool override instead of the SDK's default terminal behavior."),
149154
default: false,
150155
}),
156+
[CopilotCliConfigKey.EnableShellInitScript]: schemaProperty<boolean>({
157+
type: 'boolean',
158+
title: localize('agentHost.config.enableShellInitScript.title', "Shell Init Script"),
159+
description: localize('agentHost.config.enableShellInitScript.description', "When enabled, Copilot SDK sessions apply the shell init script published by the client before each shell command."),
160+
default: false,
161+
}),
151162
[CopilotCliConfigKey.CopilotSdkLogLevel]: schemaProperty<CopilotSdkLogLevelSetting>({
152163
type: 'string',
153164
title: localize('agentHost.config.copilotSdkLogLevel.title', "Copilot SDK Log Level"),

‎src/vs/platform/agentHost/common/sessionConfigKeys.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ export const enum SessionConfigKey {
3939
AgentMerge = 'agentMerge',
4040
/** `'agentMerge.controller'` — host-owned Agent Merge lifecycle state. */
4141
AgentMergeController = 'agentMerge.controller',
42+
/** `'shellInitScripts'` — scripts a client generated for the session, sourced before built-in shell tool commands. */
43+
ShellInitScripts = 'shellInitScripts',
4244
}
4345

4446
/**
@@ -58,3 +60,13 @@ export const KNOWN_AUTO_APPROVE_VALUES: ReadonlySet<string> = new Set(['default'
5860
* property: the agent execution mode axis.
5961
*/
6062
export const KNOWN_MODE_VALUES: ReadonlySet<string> = new Set(['interactive', 'plan', 'autopilot']);
63+
64+
/**
65+
* Removes session config that is derived from live client state and must not
66+
* survive an Agent Host restart.
67+
*/
68+
export function omitTransientSessionConfigValues<T>(values: Record<string, T>): Record<string, T> {
69+
const result = { ...values };
70+
delete result[SessionConfigKey.ShellInitScripts];
71+
return result;
72+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { encodeBase64, VSBuffer } from '../../../base/common/buffer.js';
7+
8+
export type ShellInitScriptShell = 'bash' | 'powershell';
9+
10+
/**
11+
* One client-generated script sourced before every SDK built-in shell command.
12+
* The array-valued session config carries either no script (`[]`) or this one
13+
* script, so clearing a previously applied script is explicit.
14+
*/
15+
export interface IShellInitScript {
16+
readonly shell: ShellInitScriptShell;
17+
readonly script: string;
18+
}
19+
20+
function quoteBash(value: string): string {
21+
return `'${value.replaceAll(`'`, `'\\''`)}'`;
22+
}
23+
24+
function encodedPowerShellExpression(value: string): string {
25+
const encoded = encodeBase64(VSBuffer.fromString(value));
26+
return `[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${encoded}'))`;
27+
}
28+
29+
function powerShellBlock(body: readonly string[], failureMessage: string): string[] {
30+
return [
31+
`$__vscodePreviousErrorActionPreference = $ErrorActionPreference`,
32+
`try {`,
33+
`\t$ErrorActionPreference = 'Stop'`,
34+
...body,
35+
`} catch {`,
36+
`\tWrite-Output '${failureMessage.replaceAll(`'`, `''`)}'`,
37+
`} finally {`,
38+
`\t$ErrorActionPreference = $__vscodePreviousErrorActionPreference`,
39+
`}`,
40+
];
41+
}
42+
43+
/**
44+
* Creates the single script VS Code registers with the SDK shell tool.
45+
*
46+
* Profile loading comes first so activation runs against the user's shell
47+
* setup. Activation is whatever command the Python Environments extension
48+
* published for the folder; nothing tool-specific is added here.
49+
*
50+
* Every script ends successfully unless sourced profile code itself
51+
* terminates the shell: the runtime reports a nonzero init-script status
52+
* before every later command, and discards Bash init-script stderr.
53+
*/
54+
export function createShellInitScript(shell: ShellInitScriptShell, pythonActivation: string | undefined): IShellInitScript {
55+
return shell === 'powershell'
56+
? createPowerShellInitScript(pythonActivation)
57+
: createBashInitScript(pythonActivation);
58+
}
59+
60+
function createBashInitScript(pythonActivation: string | undefined): IShellInitScript {
61+
const lines = [
62+
`# Generated by VS Code for Agent Host shell commands.`,
63+
`if [ -r "$HOME/.bashrc" ]; then`,
64+
// An rc file's status is the status of its final command. A nonzero
65+
// status therefore does not mean profile setup failed.
66+
`\tbuiltin source "$HOME/.bashrc" || builtin true`,
67+
`fi`,
68+
];
69+
if (pythonActivation?.trim()) {
70+
lines.push(
71+
`if ! builtin eval ${quoteBash(pythonActivation)}; then`,
72+
`\tprintf '%s\\n' 'copilot shell init: Python activation failed; continuing without the selected environment.'`,
73+
`fi`,
74+
);
75+
}
76+
lines.push(`builtin true`, ``);
77+
return { shell: 'bash', script: lines.join('\n') };
78+
}
79+
80+
function createPowerShellInitScript(pythonActivation: string | undefined): IShellInitScript {
81+
// Profiles load with their normal 'Continue' preference — the runtime
82+
// sources init scripts under 'Stop', which would let one benign
83+
// non-terminating profile error skip everything after it. Each profile has
84+
// its own try/catch so a broken profile does not skip the next one. A
85+
// preference changed by a profile affects later init work; the runtime may
86+
// restore its outer preference after this script completes.
87+
const lines = [
88+
`# Generated by VS Code for Agent Host shell commands.`,
89+
`$ErrorActionPreference = 'Continue'`,
90+
`foreach ($__vscodeProfile in @($PROFILE.CurrentUserAllHosts, $PROFILE.CurrentUserCurrentHost)) {`,
91+
`\ttry {`,
92+
`\t\tif ($__vscodeProfile -and (Test-Path -LiteralPath $__vscodeProfile)) {`,
93+
`\t\t\t. $__vscodeProfile`,
94+
`\t\t}`,
95+
`\t} catch {`,
96+
`\t\tWrite-Output 'copilot shell init: loading the PowerShell profile failed; continuing.'`,
97+
`\t}`,
98+
`}`,
99+
];
100+
if (pythonActivation?.trim()) {
101+
lines.push(...powerShellBlock(
102+
[`\tInvoke-Expression (${encodedPowerShellExpression(pythonActivation)})`],
103+
'copilot shell init: Python activation failed; continuing without the selected environment.',
104+
));
105+
}
106+
lines.push(`$global:LASTEXITCODE = 0`, ``);
107+
return { shell: 'powershell', script: lines.join('\n') };
108+
}
109+
110+
/** Generated scripts are a few hundred bytes; anything near this is not one. */
111+
const MAX_SHELL_INIT_SCRIPT_LENGTH = 64 * 1024;
112+
113+
/** Validates the client-pushed list before the agent host writes it to disk. */
114+
export function isShellInitScriptList(value: unknown): value is readonly IShellInitScript[] {
115+
return Array.isArray(value)
116+
&& value.length <= 1
117+
&& value.every(entry => {
118+
if (!entry || typeof entry !== 'object') {
119+
return false;
120+
}
121+
const script = entry as Partial<IShellInitScript>;
122+
return (script.shell === 'bash' || script.shell === 'powershell')
123+
&& typeof script.script === 'string'
124+
&& script.script.length > 0
125+
&& script.script.length <= MAX_SHELL_INIT_SCRIPT_LENGTH;
126+
});
127+
}

‎src/vs/platform/agentHost/node/agentService.ts‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { AgentChatMigrationDeferred, AgentProvider, AgentSession, AgentSignal, I
2323
import { type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentService } from '../common/agentService.js';
2424
import { ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js';
2525
import { IAgentEditAttributionService, ICancelEditAttributionFlushParams, ICommitEditAttributionFlushParams, IEditAttributionFlushResult, IPrepareEditAttributionFlushParams, IPreparedEditAttributionFlush, parseEditAttributionResource } from '../common/fileEditAttribution.js';
26-
import { SessionConfigKey } from '../common/sessionConfigKeys.js';
26+
import { omitTransientSessionConfigValues, SessionConfigKey } from '../common/sessionConfigKeys.js';
2727
import type { IAgentCustomizationSettingsRegistration } from '../common/agentCustomizationSettings.js';
2828
import { buildAnnotationsUri, parseAnnotationsUri } from '../common/annotationsUri.js';
2929
import { AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY, isAgentHostAutomationMigrationCompletion } from '../common/automationMigration.js';
@@ -3022,9 +3022,12 @@ export class AgentService extends Disposable implements IAgentService {
30223022
this._syncAgentMergeIndex(session, undefined, sessionConfig);
30233023
this._serverToolHost.advertise(session.toString());
30243024
// Persist resolved config values for restore. Mid-session updates are
3025-
// persisted by `AgentSideEffects` on `SessionConfigChanged`.
3025+
// persisted by `SessionFlagsContribution` on `SessionConfigChanged`.
30263026
if (sessionConfig?.values && Object.keys(sessionConfig.values).length > 0 && !created.provisional) {
3027-
this._persistConfigValues(session, sessionConfig.values);
3027+
const persistedConfigValues = omitTransientSessionConfigValues(sessionConfig.values);
3028+
if (Object.keys(persistedConfigValues).length > 0) {
3029+
this._persistConfigValues(session, persistedConfigValues);
3030+
}
30283031
}
30293032

30303033
this._changesetCoordinator.onSessionCreated(session.toString());
@@ -3754,7 +3757,10 @@ export class AgentService extends Disposable implements IAgentService {
37543757
};
37553758
const configValues = state.config?.values;
37563759
if (configValues && Object.keys(configValues).length > 0) {
3757-
this._persistConfigValues(session, configValues);
3760+
const persistedConfigValues = omitTransientSessionConfigValues(configValues);
3761+
if (Object.keys(persistedConfigValues).length > 0) {
3762+
this._persistConfigValues(session, persistedConfigValues);
3763+
}
37583764
}
37593765
// Persist the AH-owned workspace-less marker now that the session has a
37603766
// real on-disk database (deferred from create for provisional sessions).
@@ -5611,7 +5617,7 @@ export class AgentService extends Disposable implements IAgentService {
56115617

56125618
if (m.configValues) {
56135619
try {
5614-
persistedConfigValues = JSON.parse(m.configValues);
5620+
persistedConfigValues = omitTransientSessionConfigValues(JSON.parse(m.configValues));
56155621
} catch (err) {
56165622
this._logService.warn(`[AgentService] Failed to parse persisted configValues for ${sessionStr}: ${toErrorMessage(err)}`);
56175623
}

‎src/vs/platform/agentHost/node/chatContributions/sessionFlags/sessionFlagsContribution.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { IAgentHostChatContribution, IAgentHostChatContributionContext, IDi
99
import { ISessionDataService } from '../../../common/sessionDataService.js';
1010
import { ActionType } from '../../../common/state/sessionActions.js';
1111
import { AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_READ_DB_KEY } from '../../../common/state/sessionState.js';
12+
import { omitTransientSessionConfigValues } from '../../../common/sessionConfigKeys.js';
1213
import { AgentHostStateManager, IAgentHostStateManager } from '../../agentHostStateManager.js';
1314
import { persistSessionMetadata } from '../../shared/persistSessionMetadata.js';
1415

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

0 commit comments

Comments
 (0)