diff --git a/apps/buddy/electron/main/app/DesktopIntegrations.ts b/apps/buddy/electron/main/app/DesktopIntegrations.ts index f16a1da2b..d694778fa 100644 --- a/apps/buddy/electron/main/app/DesktopIntegrations.ts +++ b/apps/buddy/electron/main/app/DesktopIntegrations.ts @@ -18,6 +18,7 @@ import { extensionAgentRpc } from '../../../shared/extensions/extensionAgent' import { extensionJsonSchema } from '../../../shared/extensions/extensionApi' import { EXTENSION_REVIEW_REQUEST } from '../../../shared/extensions/extensionAuthoring' import { extensionConditionSnapshotRpc } from '../../../shared/extensions/extensionConditionContext' +import { extensionShellRpc } from '../../../shared/extensions/extensionShell' import { providerNotifications } from '../../../shared/providers/providerApi' import { runNotifications } from '../../../shared/runs/runApi' import { spaceFilesRpc, spaceTextDocumentSchema } from '../../../shared/spaces/spaceFileApi' @@ -131,6 +132,7 @@ export class DesktopIntegrations { taskActions: async () => extensionActionRpc.list.response.parse(await service.request(extensionActionRpc.list.method, {})), invokeTaskAction: async input => extensionActionRpc.invoke.response.parse(await service.request(extensionActionRpc.invoke.method, input, { timeoutMs: null })), agentChanged: catalog => service.notify(extensionAgentRpc.changed, catalog), + shellsChanged: () => service.notify(extensionShellRpc.changed, {}), agentRequest: async (input, signal) => extensionJsonSchema.parse(await service.request(extensionAgentRpc.request, input, { signal, timeoutMs: null })), readResource: (target, signal) => readSpaceFileResource(target, signal, async input => spaceFilesRpc.locate.response.parse(await service.request(spaceFilesRpc.locate.method, input, { signal }))), readText: async (target, signal) => spaceTextDocumentSchema.parse(await service.request('spaceFiles.readDocument', target, { signal })).text, @@ -141,9 +143,14 @@ export class DesktopIntegrations { this.#subscriptions.push(service.onStateChange(() => extensions.conditions.invalidate({ inputs: ['runtime.models', 'runtime.task'] }))) runtime.inspectExtension = extensions.inspect runtime.extensionAgent = extensions.agent + runtime.extensionStorage = extensions + runtime.extensionShells = extensions.shells + service.notify(extensionShellRpc.changed, {}) this.#subscriptions.push(() => { runtime.inspectExtension = null runtime.extensionAgent = null + runtime.extensionStorage = null + runtime.extensionShells = null }) this.#subscriptions.push(registerWorkbenchIpc(new WorkbenchStateStore(paths.buddyHome), () => windows.window)) this.#subscriptions.push(registerContextPanelIpc(runtime.contextPanel, () => windows.window)) diff --git a/apps/buddy/electron/main/app/DesktopRuntimeHost.ts b/apps/buddy/electron/main/app/DesktopRuntimeHost.ts index 1a8b98567..21a33a6fe 100644 --- a/apps/buddy/electron/main/app/DesktopRuntimeHost.ts +++ b/apps/buddy/electron/main/app/DesktopRuntimeHost.ts @@ -29,6 +29,7 @@ import { LexoraConfigStore } from '../config/LexoraConfigStore' import { ContextPanelHost } from '../context-panel/ContextPanelHost' import { DesktopPerformanceMonitor } from '../diagnostics/DesktopPerformanceMonitor' import { registerExtensionAuthoringRpc } from '../extensions/registerExtensionAuthoringRpc' +import { registerExtensionShellRpc } from '../extensions/registerExtensionShellRpc' import { DesktopNetwork } from '../network/DesktopNetwork' import { registerWebHostRpc } from '../network/registerWebHostRpc' import { createDesktopFeatures } from '../platform/desktopFeatures' @@ -45,6 +46,8 @@ export class DesktopRuntimeHost { readonly performance: DesktopPerformanceMonitor inspectExtension: ((id: string) => Promise) | null = null extensionAgent: ReturnType['agent'] | null = null + extensionShells: ReturnType['shells'] | null = null + extensionStorage: Pick, 'withStoppedStorage'> | null = null readonly contextPanel: ContextPanelHost readonly configStore: LexoraConfigStore readonly #environment: DesktopEnvironment @@ -81,6 +84,11 @@ export class DesktopRuntimeHost { return this.#config } + withStoppedStorage(operation: () => Promise): Promise { + const stopped = () => this.service.withStoppedStorage(operation) + return this.extensionStorage ? this.extensionStorage.withStoppedStorage(stopped) : stopped() + } + get language(): LexoraConfig['desktop']['language'] { return this.#config?.desktop.language ?? 'zh-CN' } @@ -197,6 +205,7 @@ export class DesktopRuntimeHost { sandboxProcess: { phase: event.kind, pid: event.pid, exitCode: event.exitCode }, })) const disposers = [ + registerExtensionShellRpc(peer, () => this.extensionShells), peer.onRequest(extensionAgentRpc.list, () => this.extensionAgent?.list() ?? []), peer.onRequest(extensionAgentRpc.invoke, (input, signal) => { if (!this.extensionAgent) diff --git a/apps/buddy/electron/main/extensions/registerExtensionIpc.ts b/apps/buddy/electron/main/extensions/registerExtensionIpc.ts index 5e30a173c..1b13abdf0 100644 --- a/apps/buddy/electron/main/extensions/registerExtensionIpc.ts +++ b/apps/buddy/electron/main/extensions/registerExtensionIpc.ts @@ -37,11 +37,12 @@ export function registerExtensionIpc(options: { notificationsEnabled?: () => boolean taskActions: () => Promise invokeTaskAction: (input: ExtensionTaskActionInput) => Promise + shellsChanged?: () => void agentChanged?: (catalog: ExtensionAgentCatalog) => void record?: ApplicationDiagnosticReporter conditionRuntime?: (input: { models: boolean, task: boolean, taskId: string | null, runId: string | null }, signal: AbortSignal) => Promise agentRequest?: (input: { invocationId: string, method: string, params: JsonValue }, signal: AbortSignal) => Promise -}): { themesReady: Promise, conditions: ExtensionService['conditions'], dispose: () => Promise, reviewPackage: (path: string) => Promise, inspect: (id: string) => Promise, agent: { list: () => Promise, invoke: (input: ExtensionAgentInvocation, signal: AbortSignal) => Promise } } { +}): { shells: { list: ExtensionService['shellContributions'] }, withStoppedStorage: ExtensionService['withStoppedStorage'], themesReady: Promise, conditions: ExtensionService['conditions'], dispose: () => Promise, reviewPackage: (path: string) => Promise, inspect: (id: string) => Promise, agent: { list: () => Promise, invoke: (input: ExtensionAgentInvocation, signal: AbortSignal) => Promise } } { const store = new ExtensionPackageStore(join(options.home, 'extensions'), options.version) async function reviewResult(operation: () => Promise): Promise { try { @@ -64,6 +65,7 @@ export function registerExtensionIpc(options: { event.preventDefault() } const service = new ExtensionService(store, { + shellsChanged: options.shellsChanged, themes: options.themes, createHost: (pkg, broker, failed) => { const host = new SandboxedExtensionHost(pkg, protocol, broker, failed) @@ -98,6 +100,19 @@ export function registerExtensionIpc(options: { } finally { resourcePickerOpen = false } }, + selectShellExecutable: async (name) => { + const owner = window() + if (!owner || owner.isDestroyed() || !owner.isVisible() || owner.isMinimized() || resourcePickerOpen) + throw new Error('EXTENSION_RESOURCE_PICKER_UNAVAILABLE') + resourcePickerOpen = true + try { + const result = await dialog.showOpenDialog(owner, { title: name, properties: ['openFile'] }) + if (owner.isDestroyed() || window() !== owner) + throw new Error('EXTENSION_WINDOW_UNAVAILABLE') + return result.canceled ? null : result.filePaths[0] ?? null + } + finally { resourcePickerOpen = false } + }, selectSavePath: async (name, suggestedName, signal) => { const owner = window() if (!owner || owner.isDestroyed() || !owner.isVisible() || owner.isMinimized() || resourcePickerOpen) @@ -230,6 +245,8 @@ export function registerExtensionIpc(options: { case 'invokeTaskAction': return await options.invokeTaskAction(input.input) case 'list': return await service.list() case 'unavailable': return store.unavailable + case 'detectShells': return await service.detectShells(input.id) + case 'selectShellExecutable': return await service.selectShellExecutable(input.id, input.candidate) case 'configuration': return await service.configuration(input.id) case 'configurationSnapshot': return await service.configurationSnapshot(input.id) case 'settingConditions': return await service.conditions.settings(input.id, input.items, input.form) @@ -318,7 +335,12 @@ export function registerExtensionIpc(options: { owner.webContents.send(EXTENSION_IPC.conditionsChanged, event) })) return { + shells: { list: async () => { + await prepared + return service.shellContributions() + } }, themesReady, + withStoppedStorage: operation => prepared.then(() => service.withStoppedStorage(operation)), conditions: service.conditions, dispose, agent: { diff --git a/apps/buddy/electron/main/extensions/registerExtensionShellRpc.ts b/apps/buddy/electron/main/extensions/registerExtensionShellRpc.ts new file mode 100644 index 000000000..1d2bbb7bb --- /dev/null +++ b/apps/buddy/electron/main/extensions/registerExtensionShellRpc.ts @@ -0,0 +1,7 @@ +import type { RuntimeRpcPeerContract } from '../../../shared/runtime/rpcPeer' +import type { registerExtensionIpc } from './registerExtensionIpc' +import { extensionShellRpc } from '../../../shared/extensions/extensionShell' + +export function registerExtensionShellRpc(peer: RuntimeRpcPeerContract, getShells: () => ReturnType['shells'] | null) { + return peer.onRequest(extensionShellRpc.list, () => getShells()?.list() ?? []) +} diff --git a/apps/buddy/electron/main/extensions/runtime/host.js b/apps/buddy/electron/main/extensions/runtime/host.js index 63b741a5a..332ab6e5d 100644 --- a/apps/buddy/electron/main/extensions/runtime/host.js +++ b/apps/buddy/electron/main/extensions/runtime/host.js @@ -118,6 +118,7 @@ bridge.subscribe(async ({ id, method, params }) => { agentActions.set(id, callback) return disposable(() => agentActions.delete(id)) } }, + shells: Object.freeze({ detect: () => request('shells.detect') }), network: createExtensionNetworkClient(request), notifications: { show: notification => request('notifications.show', notification) }, schedules: { diff --git a/apps/buddy/electron/main/storage/StorageBackupService.ts b/apps/buddy/electron/main/storage/StorageBackupService.ts index 5353f3ae0..937ae93f2 100644 --- a/apps/buddy/electron/main/storage/StorageBackupService.ts +++ b/apps/buddy/electron/main/storage/StorageBackupService.ts @@ -79,7 +79,7 @@ export class StorageBackupService { throw new Error('SNAPSHOT_RUNTIME_BUSY') const encrypted = options.credentials ? await encryptBackupCredentials(await exportBackupCredentials(this.environment.paths.buddyHome, options.scopes), options.password!) : undefined try { - const manifest = await this.runtime.service.withStoppedStorage(() => runSnapshotWorker(this.environment.paths.lexoraHome, this.environment.storage.state.identity!.dataId, (progress) => { + const manifest = await this.runtime.withStoppedStorage(() => runSnapshotWorker(this.environment.paths.lexoraHome, this.environment.storage.state.identity!.dataId, (progress) => { this.getWindow()?.webContents.send(STORAGE_CHANGED, progress) }, this.controller.signal, this.environment.storage.privateDirectoriesExecutable, { scopes: options.scopes, credentials: options.credentials, ...(automatic ? { automatic } : {}) }, encrypted)) await this.prune(manifest.id).catch(() => {}) diff --git a/apps/buddy/electron/preload/extensions.ts b/apps/buddy/electron/preload/extensions.ts index 1981eb42d..8dc3e2c0f 100644 --- a/apps/buddy/electron/preload/extensions.ts +++ b/apps/buddy/electron/preload/extensions.ts @@ -10,6 +10,8 @@ export function createExtensionApi(): ExtensionApi { invokeTaskAction: input => request({ action: 'invokeTaskAction', input }), list: () => request({ action: 'list' }), unavailable: () => request({ action: 'unavailable' }), + detectShells: id => request({ action: 'detectShells', id }), + selectShellExecutable: (id, candidate) => request({ action: 'selectShellExecutable', id, candidate }), configuration: id => request({ action: 'configuration', id }), configurationSnapshot: id => request({ action: 'configurationSnapshot', id }), settingConditions: (id, items, form) => request({ action: 'settingConditions', id, items, form }), diff --git a/apps/buddy/native/host/__tests__/shell_program.rs b/apps/buddy/native/host/__tests__/shell_program.rs new file mode 100644 index 000000000..e5555bf38 --- /dev/null +++ b/apps/buddy/native/host/__tests__/shell_program.rs @@ -0,0 +1,52 @@ +use super::Dialect; + +#[test] +fn dialect_uses_matching_flags_and_preserves_script_paths() { + let script = r"C:\private folder\command.sh"; + assert_eq!( + Dialect::Bash + .command_line(r"C:\Git\bin\bash.exe", script, None) + .unwrap(), + r#""C:\Git\bin\bash.exe" --noprofile --norc "C:/private folder/command.sh""# + ); + assert!( + Dialect::Powershell + .command_line(r"C:\pwsh.exe", script, None) + .is_err() + ); + assert_eq!(Dialect::Sh.script("printf 'a\nb'"), b"printf 'a\nb'"); +} + +#[test] +fn cmd_keeps_quotes_literal_and_rejects_multiline() { + assert_eq!( + Dialect::Cmd + .command_line( + r"C:\Windows\System32\cmd.exe", + "", + Some(r#"echo "hello" & echo done"#) + ) + .unwrap(), + r#""C:\Windows\System32\cmd.exe" /d /s /c "echo "hello" & echo done""# + ); + assert!( + Dialect::Cmd + .command_line( + r"C:\Windows\System32\cmd.exe", + "", + Some("echo one\necho two") + ) + .is_err() + ); +} + +#[test] +fn legacy_dialect_defaults_to_powershell_and_preserves_utf8_bom() { + let script = Dialect::default().script("Write-Output '你好'"); + assert!(script.starts_with(&[0xef, 0xbb, 0xbf])); + assert!( + String::from_utf8(script) + .unwrap() + .contains("Write-Output '你好'") + ); +} diff --git a/apps/buddy/native/host/src/shell_sandbox.rs b/apps/buddy/native/host/src/shell_sandbox.rs index 0a1efbeb8..44cc52c40 100644 --- a/apps/buddy/native/host/src/shell_sandbox.rs +++ b/apps/buddy/native/host/src/shell_sandbox.rs @@ -9,6 +9,7 @@ mod policy; pub(crate) mod process; mod security; mod setup; +mod shell_program; mod token; use std::io; @@ -17,7 +18,7 @@ const SERVICE_NAME: &str = "LexoraBuddySandboxNetwork"; const PIPE_NAME: &str = r"\\.\pipe\LexoraBuddySandboxNetwork-v1"; const PROFILE_PREFIX: &str = "Lexora.Buddy.Sandbox."; const PROTOCOL_VERSION: u32 = 1; -const COMPONENT_VERSION: u32 = 5; +const COMPONENT_VERSION: u32 = 6; pub fn run() -> io::Result { let arguments: Vec = std::env::args().skip(1).collect(); diff --git a/apps/buddy/native/host/src/shell_sandbox/process.rs b/apps/buddy/native/host/src/shell_sandbox/process.rs index 614d25958..cb256d7bb 100644 --- a/apps/buddy/native/host/src/shell_sandbox/process.rs +++ b/apps/buddy/native/host/src/shell_sandbox/process.rs @@ -30,6 +30,7 @@ use super::{ filesystem::{Access, Grant, Grants, inspect}, security::{at, boolean, current_process, owned, require_host, wide}, setup, + shell_program::Dialect, }; #[derive(Deserialize)] @@ -38,6 +39,8 @@ struct Request { command: String, cwd: String, shell: String, + #[serde(default)] + dialect: Dialect, private_root: String, environment: BTreeMap, grants: Vec, @@ -113,25 +116,23 @@ pub(super) fn run() -> io::Result { ), )?; cancellation.check()?; - let script = Path::new(&request.private_root).join("command.ps1"); - let mut script_file = OpenOptions::new() - .write(true) - .create_new(true) - .open(&script)?; - script_file.write_all( - b"\xef\xbb\xbftry { [Console]::OutputEncoding=[System.Text.Encoding]::UTF8 } catch {}\n", - )?; - script_file.write_all(request.command.as_bytes())?; - script_file.write_all( - b"\nif (-not $?) { if ($LASTEXITCODE) { exit $LASTEXITCODE } else { exit 1 } }\n", - )?; - drop(script_file); + let script = Path::new(&request.private_root) + .join(format!("command.{}", request.dialect.extension())); + if request.dialect != Dialect::Cmd { + let mut script_file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&script)?; + script_file.write_all(&request.dialect.script(&request.command))?; + } cancellation.check()?; launch_sealed = false; let (lease, child) = at( "launch", Lease::connect(LaunchRequest { shell: request.shell, + dialect: request.dialect, + command: (request.dialect == Dialect::Cmd).then_some(request.command), script: script.to_string_lossy().into_owned(), cwd: request.cwd, environment: request.environment, @@ -267,13 +268,8 @@ fn validate(request: &Request) -> io::Result<()> { return Err(io::ErrorKind::InvalidInput.into()); } } - let shell = Path::new(&request.shell) - .file_name() - .ok_or(io::ErrorKind::InvalidInput)? - .to_string_lossy(); - if !["powershell.exe", "pwsh.exe"] - .iter() - .any(|name| name.eq_ignore_ascii_case(&shell)) + request.dialect.validate(&request.shell)?; + if (request.dialect == Dialect::Cmd && request.command.contains(['\r', '\n'])) || request.command.is_empty() || request.command.len() > 1024 * 1024 || request.command.contains('\0') @@ -419,6 +415,10 @@ impl Drop for Child { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(super) struct LaunchRequest { pub(super) shell: String, + #[serde(default)] + pub(super) dialect: Dialect, + #[serde(default)] + pub(super) command: Option, pub(super) script: String, pub(super) cwd: String, pub(super) environment: BTreeMap, @@ -477,6 +477,15 @@ pub(super) fn launch( { return Err(io::ErrorKind::InvalidInput.into()); } + request.dialect.validate(&request.shell)?; + let command_line = request.dialect.command_line( + &request.shell, + &request.script, + request.command.as_deref(), + )?; + if command_line.encode_utf16().count() >= 32767 { + return Err(io::ErrorKind::InvalidInput.into()); + } let base = launch_phase("account_logon", account.logon())?; let owner = crate::windows_security::process_user_sid(client)?; let token = launch_phase( @@ -527,10 +536,7 @@ pub(super) fn launch( startup.StartupInfo.hStdOutput = output.as_raw_handle(); startup.StartupInfo.hStdError = output.as_raw_handle(); startup.lpAttributeList = attribute_list.pointer(); - let mut command = wide(&format!( - "\"{}\" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"{}\"", - request.shell, request.script - )); + let mut command = wide(&command_line); let mut environment: Vec = request .environment .iter() diff --git a/apps/buddy/native/host/src/shell_sandbox/shell_program.rs b/apps/buddy/native/host/src/shell_sandbox/shell_program.rs new file mode 100644 index 000000000..0fb170dea --- /dev/null +++ b/apps/buddy/native/host/src/shell_sandbox/shell_program.rs @@ -0,0 +1,104 @@ +use std::{io, path::Path}; + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Default, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub(super) enum Dialect { + Bash, + Zsh, + Sh, + #[default] + Powershell, + Cmd, +} + +impl Dialect { + pub(super) fn validate(self, executable: &str) -> io::Result<()> { + if !crate::windows_path::valid(executable) || executable.starts_with(r"\\") { + return Err(io::ErrorKind::InvalidInput.into()); + } + let name = Path::new(executable) + .file_name() + .ok_or(io::ErrorKind::InvalidInput)? + .to_string_lossy() + .to_ascii_lowercase(); + let accepted = match self { + Self::Bash => name == "bash.exe", + Self::Zsh => name == "zsh.exe", + Self::Sh => name == "sh.exe", + Self::Powershell => matches!(name.as_str(), "pwsh.exe" | "powershell.exe"), + Self::Cmd => name == "cmd.exe", + }; + if !accepted { + return Err(io::ErrorKind::InvalidInput.into()); + } + if matches!(self, Self::Bash | Self::Zsh | Self::Sh) { + let path = std::fs::canonicalize(executable)?; + let root = path.parent().ok_or(io::ErrorKind::InvalidInput)?; + if !root.join("msys-2.0.dll").is_file() + && !root.join("../usr/bin/msys-2.0.dll").is_file() + { + return Err(io::ErrorKind::PermissionDenied.into()); + } + } + Ok(()) + } + + pub(super) fn extension(self) -> &'static str { + match self { + Self::Powershell => "ps1", + Self::Bash | Self::Zsh | Self::Sh => "sh", + Self::Cmd => "cmd", + } + } + + pub(super) fn script(self, command: &str) -> Vec { + if self == Self::Powershell { + format!("\u{feff}try {{ [Console]::OutputEncoding=[System.Text.Encoding]::UTF8 }} catch {{}}\n{command}\nif (-not $?) {{ if ($LASTEXITCODE) {{ exit $LASTEXITCODE }} else {{ exit 1 }} }}\n").into_bytes() + } else { + command.as_bytes().to_vec() + } + } + + pub(super) fn command_line( + self, + executable: &str, + script: &str, + command: Option<&str>, + ) -> io::Result { + if self == Self::Cmd { + let command = command.ok_or(io::ErrorKind::InvalidInput)?; + if command.is_empty() || command.contains(['\0', '\r', '\n']) { + return Err(io::ErrorKind::InvalidInput.into()); + } + return Ok(format!("\"{executable}\" /d /s /c \"{command}\"")); + } + if command.is_some() + || !crate::windows_path::valid(script) + || script.starts_with(r"\\") + || Path::new(script) + .extension() + .is_none_or(|extension| extension != self.extension()) + { + return Err(io::ErrorKind::InvalidInput.into()); + } + let flags = match self { + Self::Powershell => "-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File", + Self::Bash => "--noprofile --norc", + Self::Zsh => "-f", + Self::Sh => "", + Self::Cmd => unreachable!(), + }; + let script = if self == Self::Powershell { + script.to_owned() + } else { + script.replace('\\', "/") + }; + Ok(format!("\"{executable}\" {flags} \"{script}\"")) + } +} + +#[cfg(test)] +#[path = "../../__tests__/shell_program.rs"] +mod tests; diff --git a/apps/buddy/native/host/src/supervised_shell/windows.rs b/apps/buddy/native/host/src/supervised_shell/windows.rs index cfc64d4ec..476876a1f 100644 --- a/apps/buddy/native/host/src/supervised_shell/windows.rs +++ b/apps/buddy/native/host/src/supervised_shell/windows.rs @@ -120,13 +120,29 @@ fn execute(request: &Request, cancelled: &AtomicBool, job: &Job) -> io::Result>() - .join(" "), - ); + .join(" ") + }; + let mut command = wide(&command_line); if command.len() > 32767 { return Err(io::ErrorKind::InvalidInput.into()); } diff --git a/apps/buddy/platform/extensions/ExtensionScheduler.ts b/apps/buddy/platform/extensions/ExtensionScheduler.ts index 67d7743af..abc4a3de0 100644 --- a/apps/buddy/platform/extensions/ExtensionScheduler.ts +++ b/apps/buddy/platform/extensions/ExtensionScheduler.ts @@ -67,6 +67,8 @@ export class ExtensionScheduler { clearTimeout(this.#timer) } + async drain(): Promise { await this.#tail } + async resume(): Promise { this.#suspended = false const now = this.#now() diff --git a/apps/buddy/platform/extensions/ExtensionService.ts b/apps/buddy/platform/extensions/ExtensionService.ts index fbb197fdc..353f19f8c 100644 --- a/apps/buddy/platform/extensions/ExtensionService.ts +++ b/apps/buddy/platform/extensions/ExtensionService.ts @@ -6,6 +6,8 @@ import type { ExtensionConditionState } from '../../shared/extensions/extensionC import type { ExtensionDiagnostic, ExtensionHostFailure } from '../../shared/extensions/extensionDiagnostic' import type { ExtensionFileSource, ExtensionResourceSelection } from '../../shared/extensions/extensionResources' import type { ExtensionConfiguration, ExtensionConfigurationSnapshot } from '../../shared/extensions/extensionSettings' +import type { ExtensionShellDescriptor } from '../../shared/extensions/extensionShell' +import type { ShellCandidate, ShellCandidateId } from '../../shared/shell/shellProgram' import type { SpaceFileTarget } from '../../shared/spaces/spaceFileApi' import type { WorkbenchPaneSnapshot } from '../../shared/workbench/workbenchInteraction' import type { JsonValue } from '../../shared/workbench/workbenchState' @@ -35,6 +37,7 @@ import { themeRequestSchema, themeTransportSchema } from '../../shared/theme/the import { workbenchHitRegionsSchema } from '../../shared/workbench/workbenchInteraction' import { controlProposalSchema, extensionPresentationRequestSchema } from '../../shared/workbench/workbenchUi' import { publicWebUrl, readResponseBytes } from '../network/publicWebTransport' +import { detectShellPrograms } from '../process/shellPrograms' import { ExtensionCatalogService } from './ExtensionCatalogService' import { ExtensionConditionInvalidatedError } from './ExtensionConditionEvaluator' import { ExtensionConditions } from './ExtensionConditions' @@ -60,12 +63,14 @@ export interface ExtensionServicePorts { get: (url: string, init: { signal: AbortSignal }) => Promise changed?: () => void agentChanged?: () => void + shellsChanged?: () => void conditionRuntime?: (input: { models: boolean, task: boolean, taskId: string | null, runId: string | null }, signal: AbortSignal) => Promise agentRequest?: (input: { invocationId: string, method: string, params: JsonValue }, signal: AbortSignal) => Promise notify?: (id: string, notification: { title: string, body: string }) => boolean compile?: ExtensionCompiler selectResources?: (name: string, selection: ExtensionResourceSelection & { directory?: boolean }, signal: AbortSignal) => Promise selectSavePath?: (name: string, suggestedName: string, signal: AbortSignal) => Promise + selectShellExecutable?: (name: string) => Promise } interface RunningExtension { package: ExtensionPackage @@ -116,6 +121,8 @@ export class ExtensionService { readonly #viewRequests = new Set>() readonly #viewCleanups = new Set>() #viewCleanupError: Error | undefined + readonly #shellDetections = new Map, settled: boolean, completedAt: number }>() + #shellFingerprint = '[]' #agentDescriptors: ExtensionAgentDescriptor[] = [] #agentFingerprint = '' #agentRefreshing = Promise.resolve() @@ -124,6 +131,7 @@ export class ExtensionService { #mutating = Promise.resolve() #compiling = Promise.resolve() #disposed = false + #maintenance = false #disposal: Promise | undefined #epoch = 0 @@ -147,6 +155,12 @@ export class ExtensionService { workbench: () => this.#panes.map(({ id, active, visible }) => ({ id, active, visible })), }) this.onDidChange((change) => { + if (change.kind === 'package' || change.kind === 'enabled' || (change.kind === 'configuration' && change.application.status === 'committed')) + this.#refreshShellFingerprint() + if (change.kind === 'resources-revoked') { + this.#shellDetections.get(change.extensionId)?.abort.abort() + this.#shellDetections.delete(change.extensionId) + } ports.changed?.() if (change.kind === 'contributions' && !change.initial) ports.agentChanged?.() @@ -201,6 +215,7 @@ export class ExtensionService { await this.installations.load() await this.scheduler.load() await this.#refreshAgentContributions(false) + this.#refreshShellFingerprint(false) })) } @@ -359,6 +374,160 @@ export class ExtensionService { return refresh } + #refreshShellFingerprint(notify = true): void { + const fingerprint = JSON.stringify(Object.values(this.store.installed).filter(record => record.current.manifest.contributes.shells).map(({ current, enabled }) => { + let dependencies: unknown = null + try { + dependencies = this.#order(current.manifest.id).map(id => this.store.installed[id]!.current.revision) + } + catch {} + return [current.manifest.id, current.revision, enabled, dependencies, this.#configurationApplications.get(current.manifest.id)?.configurationRevision ?? ''] + }).sort()) + if (fingerprint === this.#shellFingerprint) + return + this.#shellFingerprint = fingerprint + for (const detection of this.#shellDetections.values()) + detection.abort.abort() + this.#shellDetections.clear() + if (notify) + this.#ports.shellsChanged?.() + } + + #shellAccess(id: string) { + if (this.#disposed || this.#maintenance) + throw new Error('EXTENSION_HOST_STOPPED') + const dependencies = this.#order(id) + const pkg = this.store.installed[id]!.current + if (!pkg.manifest.permissions.shells || !pkg.manifest.contributes.shells) + throw new Error('EXTENSION_PERMISSION_DENIED') + const epoch = this.#epoch + let invalidated: string | undefined + const dispose = this.onDidChange((change) => { + if (!('extensionId' in change) || !dependencies.includes(change.extensionId)) + return + if (change.kind === 'configuration' && change.application.status === 'committed') + invalidated = 'EXTENSION_CONFIGURATION_UPDATE_STALE' + else if (change.kind === 'resources-revoked' || (change.kind === 'enabled' && !change.enabled) || (change.kind === 'package' && change.action !== 'installed')) + invalidated = 'EXTENSION_HOST_STOPPED' + }) + const assertCurrent = () => { + if (invalidated) + throw new Error(invalidated) + if (this.#disposed || this.#maintenance || epoch !== this.#epoch || this.store.installed[id]?.current.revision !== pkg.revision) + throw new Error('EXTENSION_HOST_STOPPED') + this.#order(id) + if (!this.store.installed[id]!.current.manifest.permissions.shells) + throw new Error('EXTENSION_PERMISSION_DENIED') + } + return { pkg, assertCurrent, dispose: () => dispose.dispose() } + } + + async detectShells(id: string): Promise { + await this.initialize() + await this.#mutating + return (await this.#detectShells(id, true)).candidates + } + + async selectShellExecutable(id: string, candidate: ShellCandidateId): Promise { + await this.initialize() + await this.#mutating + const access = this.#shellAccess(id) + try { + if (!access.pkg.manifest.contributes.shells!.candidates.some(item => item.id === candidate)) + throw new Error('EXTENSION_INVALID_ARGUMENT') + if (!this.#ports.selectShellExecutable) + throw new Error('EXTENSION_RESOURCE_PICKER_UNAVAILABLE') + const selected = await this.#ports.selectShellExecutable(access.pkg.manifest.name) + await this.#mutating + access.assertCurrent() + return selected + } + finally { access.dispose() } + } + + async #detectShells(id: string, refresh = false): Promise<{ candidates: ShellCandidate[], configuration: ExtensionConfiguration, configurationRevision: string }> { + const access = this.#shellAccess(id) + try { + return await this.#readShellDetection(id, refresh, access) + } + finally { access.dispose() } + } + + async #readShellDetection(id: string, refresh: boolean, access: { pkg: ExtensionPackage, assertCurrent: () => void }): Promise<{ candidates: ShellCandidate[], configuration: ExtensionConfiguration, configurationRevision: string }> { + const { manifest, revision } = access.pkg + const shells = manifest.contributes.shells! + const configuration = await this.store.configuration(id) + access.assertCurrent() + const configurationRevision = sha256(JSON.stringify(configuration)) + const fingerprint = `${revision}:${configurationRevision}` + let detection = this.#shellDetections.get(id) + if (!detection || detection.fingerprint !== fingerprint || (refresh && detection.settled && Date.now() - detection.completedAt >= 1000)) { + const previous = detection + previous?.abort.abort() + const configured: Partial> = {} + for (const candidate of shells.candidates) { + const executable = configuration[candidate.executable] + if (typeof executable === 'string' && executable.trim()) + configured[candidate.id] = executable.trim() + } + const abort = new AbortController() + const next = { fingerprint, abort, settled: false, completedAt: 0, promise: Promise.resolve([] as ShellCandidate[]) } + next.promise = detectShellPrograms({ configured, signal: abort.signal }).then((candidates) => { + abort.signal.throwIfAborted() + const filtered = candidates.filter(candidate => shells.candidates.some(item => item.id === candidate.id)) + if (refresh && previous) { + void previous.promise.then((value) => { + if (JSON.stringify(value) !== JSON.stringify(filtered)) + this.#ports.shellsChanged?.() + }).catch(() => {}) + } + return filtered + }).finally(() => { + next.settled = true + next.completedAt = Date.now() + }) + this.#shellDetections.set(id, next) + detection = next + } + try { + const candidates = await detection.promise + detection.abort.signal.throwIfAborted() + if (this.store.installed[id]?.current.revision !== revision || !this.store.installed[id]?.enabled) + throw new Error('EXTENSION_HOST_STOPPED') + if (sha256(JSON.stringify(await this.store.configuration(id))) !== configurationRevision) + throw new Error('EXTENSION_CONFIGURATION_UPDATE_STALE') + access.assertCurrent() + return { candidates: structuredClone(candidates), configuration, configurationRevision } + } + catch (error) { + if (this.#shellDetections.get(id) === detection) + this.#shellDetections.delete(id) + throw error + } + } + + async shellContributions(): Promise { + await this.initialize() + await this.#mutating + if (this.#disposed || this.#maintenance) + return [] + const descriptors = await Promise.all(Object.values(this.store.installed).filter(record => record.enabled && record.current.manifest.contributes.shells).map(async ({ current }) => { + try { + const { candidates, configuration, configurationRevision } = await this.#detectShells(current.manifest.id) + const enabled = current.manifest.contributes.shells!.candidates.filter(candidate => configuration[candidate.enabled] === true) + return { + extensionId: current.manifest.id, + revision: current.revision, + configurationRevision, + candidates: candidates.filter(candidate => enabled.some(item => item.id === candidate.id)), + preferred: enabled.filter(candidate => Number(configuration[candidate.priority]) > 0).sort((a, b) => Number(configuration[a.priority]) - Number(configuration[b.priority])).map(candidate => candidate.id), + } + } + catch { return null } + })) + return descriptors.filter((descriptor): descriptor is ExtensionShellDescriptor => descriptor !== null) + } + async invokeAgent(input: ExtensionAgentInvocation, signal: AbortSignal): Promise { signal.throwIfAborted() const contribution = (await this.agentContributions()).find(item => item.id === input.extensionId) @@ -1003,13 +1172,17 @@ export class ExtensionService { if (this.#disposal) return this.#disposal this.#disposed = true + const detecting = [...this.#shellDetections.values()].map(detection => detection.promise) + for (const detection of this.#shellDetections.values()) + detection.abort.abort() + this.#shellDetections.clear() this.#disposal = Promise.resolve().then(async () => { this.conditions.dispose() this.scheduler.dispose() this.catalog.dispose() const stopping = this.resetHosts() try { - const results = await Promise.allSettled([stopping, this.#mutating, this.#compiling, ...this.#viewRequests]) + const results = await Promise.allSettled([stopping, this.#mutating, this.#compiling, ...this.#viewRequests, ...detecting]) while (this.#stopping.size) await Promise.allSettled([...this.#stopping]) await Promise.all([...this.#retired].map(running => running.inFlight ? new Promise(resolve => running.drained.add(resolve)) : Promise.resolve())) @@ -1033,11 +1206,46 @@ export class ExtensionService { await Promise.all([...this.#running.keys()].map(id => this.#stop(id))) } + async withStoppedStorage(operation: () => Promise): Promise { + await this.initialize() + if (this.#disposed || this.#maintenance) + throw new Error('EXTENSION_STORAGE_BUSY') + this.#maintenance = true + const detecting = [...this.#shellDetections.values()].map(detection => detection.promise) + for (const detection of this.#shellDetections.values()) + detection.abort.abort() + this.#shellDetections.clear() + await Promise.allSettled(detecting) + const active = [...this.#running.keys()] + this.scheduler.suspend() + try { + await this.installations.dispose() + await Promise.all([this.#mutating, this.#compiling]) + await this.resetHosts() + while (this.#stopping.size) + await Promise.all([...this.#stopping]) + await Promise.all([...this.#retired].map(running => running.inFlight ? new Promise(resolve => running.drained.add(resolve)) : Promise.resolve())) + await Promise.allSettled([...this.#viewRequests]) + await Promise.all([...this.#viewCleanups]) + if (this.#viewCleanupError) + throw this.#viewCleanupError + await this.scheduler.drain() + return await operation() + } + finally { + this.#maintenance = false + if (!this.#disposed) { + await this.scheduler.resume() + await Promise.allSettled(active.map(id => this.#activate(id))) + } + } + } + async #activate(id: string): Promise { const epoch = this.#epoch await this.initialize() await this.#mutating - if (this.#disposed || epoch !== this.#epoch) + if (this.#disposed || this.#maintenance || epoch !== this.#epoch) throw new Error('EXTENSION_HOST_STOPPED') for (const dependency of this.#order(id)) { if (this.#diagnostics.get(dependency)?.error) @@ -1099,7 +1307,7 @@ export class ExtensionService { #assertCurrent(running: RunningExtension): void { const id = running.package.manifest.id const installed = this.store.installed[id] - if (this.#disposed || running.abort.signal.aborted || this.#running.get(id) !== running || !installed?.enabled || installed.current.revision !== running.package.revision) + if (this.#disposed || this.#maintenance || running.abort.signal.aborted || this.#running.get(id) !== running || !installed?.enabled || installed.current.revision !== running.package.revision) throw new Error('EXTENSION_HOST_STOPPED') } @@ -1159,6 +1367,11 @@ export class ExtensionService { else if (method === 'themes.request') { result = await this.#themeRequest(running, `${id}:${running.generation}`, params, () => this.#assertCurrent(running)) } + else if (method === 'shells.detect') { + z.null().parse(params) + const detected = await this.#detectShells(id, true) + result = detected.candidates.map(candidate => ({ ...candidate })) + } else if (method === 'configuration.get') { result = await this.store.configuration(id) } @@ -1558,7 +1771,7 @@ export class ExtensionService { #mutate(operation: () => Promise): Promise { const task = this.#mutating.catch(() => {}).then(async () => { await this.initialize() - if (this.#disposed) + if (this.#disposed || this.#maintenance) throw new Error('EXTENSION_HOST_STOPPED') try { await operation() diff --git a/apps/buddy/platform/extensions/__tests__/ExtensionCatalogService.spec.ts b/apps/buddy/platform/extensions/__tests__/ExtensionCatalogService.spec.ts index 49af778cb..4cc12077a 100644 --- a/apps/buddy/platform/extensions/__tests__/ExtensionCatalogService.spec.ts +++ b/apps/buddy/platform/extensions/__tests__/ExtensionCatalogService.spec.ts @@ -62,7 +62,7 @@ it('shows only the latest release and refuses older marketplace versions', async it('isolates future APIs and invalid entries while preserving exact supported downloads and offline metadata', async () => { const { service, catalog, value, bytes, offline } = await fixture() const raw = catalog.plugins as unknown[] - raw.push({ ...catalog.plugins[0], manifest: { ...value, id: 'tests.future', apiVersion: 4, engines: { lexora: '*' }, contributes: { futureCapability: {} } } }) + raw.push({ ...catalog.plugins[0], manifest: { ...value, id: 'tests.future', apiVersion: 5, engines: { lexora: '*' }, contributes: { futureCapability: {} } } }) raw.push({ ...catalog.plugins[0], manifest: { ...value, id: 'tests.invalid', engines: { lexora: '*' }, futureField: true } }) raw.push({ broken: true }) const first = await service.list() diff --git a/apps/buddy/platform/extensions/__tests__/ExtensionPackageStore.spec.ts b/apps/buddy/platform/extensions/__tests__/ExtensionPackageStore.spec.ts index 8a77bd5cb..1940c1f42 100644 --- a/apps/buddy/platform/extensions/__tests__/ExtensionPackageStore.spec.ts +++ b/apps/buddy/platform/extensions/__tests__/ExtensionPackageStore.spec.ts @@ -30,7 +30,7 @@ describe('extension package contract', () => { }) it('rejects foreign contribution ownership, duplicate IDs, and unsupported API versions', () => { const value = manifest() - expect(extensionManifestSchema.safeParse({ ...value, apiVersion: 4 }).success).toBe(false) + expect(extensionManifestSchema.safeParse({ ...value, apiVersion: 5 }).success).toBe(false) expect(extensionManifestSchema.safeParse({ ...value, contributes: { ...value.contributes, commands: [{ id: 'another.reader.open', title: 'Open' }] } }).success).toBe(false) expect(extensionManifestSchema.safeParse({ ...value, contributes: { ...value.contributes, commands: [value.contributes.commands[0], value.contributes.commands[0]] } }).success).toBe(false) }) @@ -131,7 +131,7 @@ describe('extension package contract', () => { const path = join(store.root, 'installed.json') const index = JSON.parse(await readFile(path, 'utf8')) const future = structuredClone(index.installed[review.manifest.id]) - future.current.manifest = { ...future.current.manifest, id: 'tests.future', apiVersion: 4, engines: { lexora: '*' }, contributes: { futureCapability: {} } } + future.current.manifest = { ...future.current.manifest, id: 'tests.future', apiVersion: 5, engines: { lexora: '*' }, contributes: { futureCapability: {} } } index.installed['tests.future'] = future await writeFile(path, JSON.stringify(index)) const candidate = new ExtensionPackageStore(store.root, store.appVersion) @@ -149,7 +149,7 @@ describe('extension package contract', () => { const path = join(store.root, 'installed.json') const index = JSON.parse(await readFile(path, 'utf8')) const record = index.installed[review.manifest.id] - const pending = { ...structuredClone(record.current), manifest: { ...record.current.manifest, apiVersion: 4, version: '2.0.0', engines: { lexora: '*' }, contributes: { futureCapability: {} } } } + const pending = { ...structuredClone(record.current), manifest: { ...record.current.manifest, apiVersion: 5, version: '2.0.0', engines: { lexora: '*' }, contributes: { futureCapability: {} } } } record.pending = pending await writeFile(path, JSON.stringify(index)) const candidate = new ExtensionPackageStore(store.root, store.appVersion) diff --git a/apps/buddy/platform/extensions/__tests__/ExtensionShell.spec.ts b/apps/buddy/platform/extensions/__tests__/ExtensionShell.spec.ts new file mode 100644 index 000000000..d2a9b82dc --- /dev/null +++ b/apps/buddy/platform/extensions/__tests__/ExtensionShell.spec.ts @@ -0,0 +1,309 @@ +import type { ShellCandidate } from '../../../shared/shell/shellProgram' +import type { JsonValue } from '../../../shared/workbench/workbenchState' +import type { ExtensionServicePorts } from '../ExtensionService' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { extensionManagementSchema } from '../../../shared/extensions/extensionApi' +import { addedExtensionPermissions, extensionManifestSchema } from '../../../shared/extensions/extensionManifest' +import * as programs from '../../process/shellPrograms' +import { ExtensionPackageStore } from '../ExtensionPackageStore' +import { ExtensionService } from '../ExtensionService' +import { reviewPackage } from './fixtures' + +const id = 'tests.shells' +const candidates: ShellCandidate[] = [ + { id: 'bash', label: 'Bash', dialect: 'bash', executable: '/usr/bin/bash', version: '5.2', available: true }, + { id: 'zsh', label: 'Zsh', dialect: 'zsh', executable: '', version: '', available: false, reason: 'No usable local installation was found' }, +] +const cleanup: Array<() => Promise> = [] +afterEach(async () => { + for (const action of cleanup.splice(0).reverse()) + await action() + vi.restoreAllMocks() +}) +function definition() { + return extensionManifestSchema.parse({ + schemaVersion: 1, + id, + name: 'Shells', + version: '1.0.0', + apiVersion: 4, + engines: { lexora: '>=0.14.0 <1.0.0' }, + entry: 'extension.js', + permissions: { shells: true }, + contributes: { + commands: [{ id: `${id}.open`, title: 'Detect' }], + shells: { candidates: ['bash', 'zsh'].map(key => ({ id: key, enabled: `${key}Enabled`, executable: `${key}Executable`, priority: `${key}Priority` })) }, + settings: { + modules: [{ id: `${id}.execution`, title: 'Execution' }], + groups: [{ id: `${id}.group`, module: `${id}.execution`, title: 'Shells' }], + items: ['bash', 'zsh'].flatMap(key => [ + { id: `${id}.${key}.enabled`, key: `${key}Enabled`, group: `${id}.group`, title: 'Enabled', type: 'boolean', default: false }, + { id: `${id}.${key}.path`, key: `${key}Executable`, group: `${id}.group`, title: 'Path', type: 'string', default: '' }, + { id: `${id}.${key}.priority`, key: `${key}Priority`, group: `${id}.group`, title: 'Priority', type: 'number', default: 0, min: 0, max: 100 }, + ]), + }, + }, + }) +} + +async function fixture(ports: Partial = {}, manifest = definition()) { + const root = await mkdtemp(join(tmpdir(), 'lexora-shell-contract-')) + cleanup.push(() => rm(root, { recursive: true, force: true })) + const store = new ExtensionPackageStore(join(root, 'extensions'), '0.14.0') + await store.load() + await store.install((await reviewPackage(root, store, manifest)).token) + let broker!: (method: string, params: unknown) => Promise + let changes = 0 + const service = new ExtensionService(store, { + createHost: (_pkg, request) => { + broker = request + return { call: async () => null, dispose: async () => {}, devtools: () => {} } + }, + createView: () => { throw new Error('No view expected') }, + workbench: async () => null, + readText: async () => '', + get: async () => new Response(''), + shellsChanged: () => { changes++ }, + ...ports, + }) + cleanup.push(() => service.dispose()) + return { root, store, service, broker: (method: string, params: unknown) => broker(method, params), changes: () => changes } +} + +it('binds the native shell table only to owned groups containing every candidate setting', () => { + const manifest = definition() + const grouped = { ...manifest, contributes: { ...manifest.contributes, shells: { ...manifest.contributes.shells!, settingsGroup: `${id}.group` } } } + expect(extensionManifestSchema.parse(grouped).apiVersion).toBe(4) + for (const settingsGroup of ['settings.runtime', `${id}.missing`, 'other.plugin.group']) { + expect(extensionManifestSchema.safeParse({ ...grouped, contributes: { ...grouped.contributes, shells: { ...grouped.contributes.shells, settingsGroup } } }).success).toBe(false) + } + for (const key of ['bashEnabled', 'bashExecutable', 'bashPriority']) { + const settings = grouped.contributes.settings + expect(extensionManifestSchema.safeParse({ ...grouped, contributes: { ...grouped.contributes, settings: { + ...settings, + groups: [...settings.groups, { id: `${id}.other`, module: 'settings.runtime', title: 'Other' }], + items: settings.items.map(item => item.key === key ? { ...item, group: `${id}.other` } : item), + } } }).success).toBe(false) + } +}) + +it('accepts only declared native management request shapes without paths or probe parameters', () => { + expect(extensionManagementSchema.parse({ action: 'detectShells', id })).toEqual({ action: 'detectShells', id }) + expect(extensionManagementSchema.parse({ action: 'selectShellExecutable', id, candidate: 'bash' })).toEqual({ action: 'selectShellExecutable', id, candidate: 'bash' }) + for (const input of [ + { action: 'detectShells', id, configured: { bash: '/tmp/program' } }, + { action: 'selectShellExecutable', id, candidate: 'arbitrary' }, + { action: 'selectShellExecutable', id, candidate: 'bash', path: '/tmp/program' }, + { action: 'selectShellExecutable', id, candidate: 'bash', command: '--version' }, + ]) { + expect(extensionManagementSchema.safeParse(input).success).toBe(false) + } +}) + +it('detects all candidates natively without activating a plugin entry and returns independent snapshots', async () => { + vi.spyOn(programs, 'detectShellPrograms').mockResolvedValue(candidates) + const manifest = definition() + delete manifest.entry + manifest.contributes.commands = [] + const { service } = await fixture({ createHost: () => { + throw new Error('Unexpected plugin activation') + } }, manifest) + const result = await service.detectShells(id) + expect(result).toEqual(candidates) + result[0]!.executable = '/tmp/replaced' + expect(await service.detectShells(id)).toEqual(candidates) +}) + +it('selects or cancels without probing, writing configuration or granting file access', async () => { + vi.spyOn(programs, 'detectShellPrograms').mockImplementation(() => { + throw new Error('Unexpected probe') + }) + let selected: string | null = '/opt/trusted/bash' + const { service, store, broker } = await fixture({ selectShellExecutable: async () => selected }) + await service.initialize() + const before = await service.configuration(id) + const grants = await store.resources.list(id) + expect(await service.selectShellExecutable(id, 'bash')).toBe(selected) + selected = null + expect(await service.selectShellExecutable(id, 'bash')).toBeNull() + expect(await service.configuration(id)).toEqual(before) + expect(await store.resources.list(id)).toEqual(grants) + await service.execute(id, `${id}.open`, null) + await expect(broker('shells.selectExecutable', { candidate: 'bash' })).rejects.toThrow('EXTENSION_METHOD_DENIED') +}) + +it('rejects disabled, uninstalled, unpermitted and undeclared shell selections before opening a picker', async () => { + const { service, store, root } = await fixture({ selectShellExecutable: async () => { + throw new Error('Unexpected picker') + } }) + await expect(service.selectShellExecutable(id, 'cmd')).rejects.toThrow('EXTENSION_INVALID_ARGUMENT') + await service.enable(id, false) + await expect(service.selectShellExecutable(id, 'bash')).rejects.toThrow('EXTENSION_DEPENDENCY_UNAVAILABLE') + await expect(service.detectShells(id)).rejects.toThrow('EXTENSION_DEPENDENCY_UNAVAILABLE') + await service.uninstall(id) + await expect(service.selectShellExecutable(id, 'bash')).rejects.toThrow('EXTENSION_DEPENDENCY_UNAVAILABLE') + const manifest = definition() + delete manifest.contributes.shells + manifest.permissions.shells = false + await service.install((await reviewPackage(root, store, manifest)).token) + await expect(service.selectShellExecutable(id, 'bash')).rejects.toThrow('EXTENSION_PERMISSION_DENIED') + await expect(service.detectShells(id)).rejects.toThrow('EXTENSION_PERMISSION_DENIED') +}) + +it.each(['disable', 'revoke', 'configure', 'replace', 'reset'] as const)('rejects a selected path after %s while the dialog is open', async (change) => { + let complete!: (value: string | null) => void + const { service, root, store } = await fixture({ + selectShellExecutable: () => new Promise((resolve) => { complete = resolve }), + }) + const selected = service.selectShellExecutable(id, 'bash') + await vi.waitFor(() => expect(complete).toBeTypeOf('function')) + if (change === 'disable') { + await service.enable(id, false) + await service.enable(id, true) + } + else if (change === 'revoke') { + await service.revokeResources(id) + } + else if (change === 'configure') { + await service.configure(id, { bashExecutable: '/opt/new/bash' }) + } + else if (change === 'replace') { + await service.install((await reviewPackage(root, store, extensionManifestSchema.parse({ ...definition(), version: '1.1.0' }))).token) + await service.applyUpdate(id) + } + else { + await service.resetHosts() + } + complete('/opt/old/bash') + await expect(selected).rejects.toThrow(change === 'configure' ? 'EXTENSION_CONFIGURATION_UPDATE_STALE' : 'EXTENSION_HOST_STOPPED') + expect((await service.configuration(id)).bashExecutable).toBe(change === 'configure' ? '/opt/new/bash' : '') +}) + +it.each(['revoke', 'configure'] as const)('rejects native detection invalidated by %s', async (change) => { + let complete!: (value: ShellCandidate[]) => void + vi.spyOn(programs, 'detectShellPrograms').mockImplementation(() => new Promise((resolve) => { + complete = resolve + })) + const { service } = await fixture() + const pending = service.detectShells(id) + await vi.waitFor(() => expect(complete).toBeTypeOf('function')) + if (change === 'revoke') + await service.revokeResources(id) + else + await service.configure(id, { bashExecutable: '/opt/new/bash' }) + complete(candidates) + await expect(pending).rejects.toThrow() +}) + +it('does not probe an obsolete configuration returned after a concurrent save', async () => { + const { service, store } = await fixture() + await service.initialize() + const previous = await store.configuration(id) + let complete!: () => void + vi.spyOn(store, 'configuration').mockImplementationOnce(() => new Promise((resolve) => { + complete = () => resolve(previous) + })) + vi.spyOn(programs, 'detectShellPrograms').mockImplementation(() => { + throw new Error('Unexpected stale probe') + }) + const pending = service.detectShells(id) + await vi.waitFor(() => expect(complete).toBeTypeOf('function')) + await service.configure(id, { bashExecutable: '/opt/new/bash' }) + complete() + await expect(pending).rejects.toThrow('EXTENSION_CONFIGURATION_UPDATE_STALE') + expect((await service.configuration(id)).bashExecutable).toBe('/opt/new/bash') +}) + +it('requires a separate reviewed API 4 permission and safe native setting defaults', () => { + const manifest = definition() + expect(addedExtensionPermissions(undefined, manifest.permissions)).toEqual(['shells']) + expect(extensionManifestSchema.safeParse({ ...manifest, apiVersion: 3 }).success).toBe(false) + expect(extensionManifestSchema.safeParse({ ...manifest, permissions: {} }).success).toBe(false) + for (const patch of [{ id: 'arbitrary' }, { command: 'echo unsafe' }, { executable: '/tmp/program' }, { enabled: 'bashExecutable' }]) { + const value = { ...manifest, contributes: { ...manifest.contributes, shells: { candidates: [{ ...manifest.contributes.shells!.candidates[0], ...patch }] } } } + expect(extensionManifestSchema.safeParse(value).success).toBe(false) + } + for (const type of ['boolean', 'string', 'number']) { + const items = manifest.contributes.settings.items.map(item => item.type === type ? { ...item, default: type === 'boolean' ? true : type === 'string' ? '/tmp/program' : 1 } : item) + expect(extensionManifestSchema.safeParse({ ...manifest, contributes: { ...manifest.contributes, settings: { ...manifest.contributes.settings, items } } }).success).toBe(false) + } +}) + +it('keeps platform defaults, filters enabled candidates, retains unavailable reasons and uses only native configuration', async () => { + vi.spyOn(programs, 'detectShellPrograms').mockImplementation(async ({ configured } = {}) => candidates.map(candidate => ({ ...candidate, executable: configured?.[candidate.id] ?? candidate.executable }))) + const { service, broker, changes } = await fixture() + expect(await service.shellContributions()).toEqual([expect.objectContaining({ extensionId: id, candidates: [], preferred: [] })]) + expect(changes()).toBe(0) + await service.execute(id, `${id}.open`, null) + expect(await broker('shells.detect', null)).toEqual(candidates) + await expect(broker('shells.detect', { configured: { bash: '/tmp/program' } })).rejects.toThrow('EXTENSION_INVALID_ARGUMENT') + await expect(broker('configuration.set', { bashExecutable: '/tmp/program' })).rejects.toThrow('EXTENSION_METHOD_DENIED') + await expect(broker('shell.execute', { command: 'echo unsafe' })).rejects.toThrow('EXTENSION_METHOD_DENIED') + await broker('storage.set', { value: { bashExecutable: '/tmp/program' }, version: 1 }) + expect((await service.configuration(id)).bashExecutable).toBe('') + await service.configure(id, { bashEnabled: true, zshEnabled: true, bashExecutable: '/opt/trusted/bash', bashPriority: 2, zshPriority: 1 }) + const descriptors = await service.shellContributions() + expect(descriptors).toEqual([expect.objectContaining({ + candidates: [{ ...candidates[0], executable: '/opt/trusted/bash' }, candidates[1]], + preferred: ['zsh', 'bash'], + })]) + expect(changes()).toBe(1) + await service.configure(id, { bashPriority: 2 }) + expect(changes()).toBe(1) +}) + +it('withdraws disabled, uninstalled and replaced packages while preserving user settings', async () => { + vi.spyOn(programs, 'detectShellPrograms').mockResolvedValue(candidates) + const { root, store, service } = await fixture() + await service.configure(id, { bashEnabled: true, bashPriority: 1 }) + const initial = (await service.shellContributions())[0]! + await service.enable(id, false) + expect(await service.shellContributions()).toEqual([]) + await service.enable(id, true) + expect(await service.shellContributions()).toEqual([initial]) + const updated = extensionManifestSchema.parse({ ...definition(), version: '1.1.0' }) + await service.install((await reviewPackage(root, store, updated)).token) + expect(await service.shellContributions()).toEqual([initial]) + await service.applyUpdate(id) + const next = (await service.shellContributions())[0]! + expect(next.revision).not.toBe(initial.revision) + expect(next.candidates).toEqual(initial.candidates) + expect(next.configurationRevision).toBe(initial.configurationRevision) + await service.uninstall(id) + expect(await service.shellContributions()).toEqual([]) + await service.install((await reviewPackage(root, store, updated)).token) + expect((await service.shellContributions())[0]!.preferred).toEqual(['bash']) +}) + +it('reuses configuration fingerprints but refreshes detection from the read-only UI', async () => { + let output = candidates + vi.spyOn(programs, 'detectShellPrograms').mockImplementation(async () => output) + const { service, broker, changes } = await fixture() + await service.configure(id, { bashEnabled: true }) + await service.execute(id, `${id}.open`, null) + expect((await service.shellContributions())[0]!.candidates[0]!.version).toBe('5.2') + output = [{ ...candidates[0]!, version: '5.3' }, candidates[1]!] + expect((await service.shellContributions())[0]!.candidates[0]!.version).toBe('5.2') + const now = Date.now() + vi.spyOn(Date, 'now').mockReturnValue(now + 1100) + expect(await broker('shells.detect', null)).toEqual(output) + expect((await service.shellContributions())[0]!.candidates[0]!.version).toBe('5.3') + expect(changes()).toBe(2) +}) + +it('rejects stale detection when disabled before a pending probe completes', async () => { + let complete!: (value: ShellCandidate[]) => void + vi.spyOn(programs, 'detectShellPrograms').mockImplementation(() => new Promise((resolve) => { + complete = resolve + })) + const { service } = await fixture() + await service.configure(id, { bashEnabled: true }) + const pending = service.shellContributions() + await vi.waitFor(() => expect(complete).toBeTypeOf('function')) + await service.enable(id, false) + complete(candidates) + expect(await pending).toEqual([]) +}) diff --git a/apps/buddy/platform/process/__tests__/shellPrograms.spec.ts b/apps/buddy/platform/process/__tests__/shellPrograms.spec.ts new file mode 100644 index 000000000..4437abe0a --- /dev/null +++ b/apps/buddy/platform/process/__tests__/shellPrograms.spec.ts @@ -0,0 +1,81 @@ +import { Buffer } from 'node:buffer' +import process from 'node:process' +import { describe, expect, it } from 'vitest' +import { shellCandidateIdSchema, shellCandidateSchema, shellProgramSchema } from '../../../shared/shell/shellProgram' +import { buildShellInvocation, createShellProgramEnvironment, detectShellPrograms } from '../shellPrograms' +import { runSupervisedShellProcess } from '../supervisedShellProcess' + +describe('shell program protocol', () => { + it('keeps source as a single argument and uses dialect-specific noninteractive flags', () => { + const command = 'printf "%s\\n" "quoted";\nprintf second' + expect(buildShellInvocation({ dialect: 'bash', executable: '/bin/bash' }, command)).toEqual({ + shell: '/bin/bash', + args: ['--noprofile', '--norc', '-c'], + command, + }) + expect(buildShellInvocation({ dialect: 'zsh', executable: '/bin/zsh' }, command).args).toEqual(['-f', '-c']) + expect(buildShellInvocation({ dialect: 'sh', executable: '/bin/sh' }, command).args).toEqual(['-c']) + expect(shellProgramSchema.safeParse({ dialect: 'bash', executable: 'bash' }).success).toBe(false) + expect(shellProgramSchema.safeParse({ dialect: 'cmd', executable: 'C:\\Windows\\System32\\cmd.exe' }).success).toBe(true) + }) + + it('encodes PowerShell losslessly and rejects CMD multiline instead of truncating', () => { + const command = 'Write-Output "你好";\nexit 7' + const invocation = buildShellInvocation({ dialect: 'powershell', executable: '/usr/bin/pwsh' }, command) + expect(invocation.args).toEqual(['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand']) + expect(Buffer.from(invocation.command, 'base64').toString('utf16le')).toBe(command) + expect(() => buildShellInvocation({ dialect: 'cmd', executable: 'C:\\Windows\\System32\\cmd.exe' }, 'echo first\necho second')).toThrow('CMD_MULTILINE_UNSUPPORTED') + }) + + it('returns the fixed candidate set and honors missing explicit executables without fallback', async () => { + const configured = Object.fromEntries(shellCandidateIdSchema.options.map(id => [id, '/__lexora_missing_shell__/shell'])) + const candidates = await detectShellPrograms({ configured }) + expect(candidates.map(candidate => candidate.id)).toEqual(shellCandidateIdSchema.options) + for (const candidate of candidates) { + expect(shellCandidateSchema.parse(candidate)).toEqual(candidate) + expect(candidate.available).toBe(false) + expect(candidate.executable).toBe('') + } + }) + + it('removes implicit startup code without dropping explicit host tool paths', () => { + const environment = createShellProgramEnvironment({ dialect: 'bash', executable: 'C:\\Program Files\\Git\\bin\\bash.exe' }, { + 'Path': 'C:\\tools', + 'bash_env': 'C:\\startup.sh', + 'ENV': 'C:\\startup.sh', + 'ZDOTDIR': 'C:\\profile', + 'SHELLOPTS': 'xtrace', + 'BASH_FUNC_injected%%': '() { echo injected; }', + 'CUSTOM': 'preserved', + }, 'win32') + expect(environment).toEqual({ CUSTOM: 'preserved', PATH: expect.any(String) }) + expect(environment.PATH?.split(';')).toContain('C:\\Program Files\\Git\\usr\\bin') + expect(environment.PATH?.split(';')).toContain('C:\\tools') + }) + + it('honors cancellation before detection', async () => { + await expect(detectShellPrograms({ signal: AbortSignal.abort() })).rejects.toThrow() + }) +}) + +describe.skipIf(process.platform !== 'linux')('installed shells under native supervision', () => { + it('preserves quotes, multiline, Unicode and explicit exit codes without changing shell', async () => { + const candidates = await detectShellPrograms() + for (const candidate of candidates.filter(candidate => candidate.available && ['bash', 'sh', 'zsh'].includes(candidate.dialect))) { + const chunks: Buffer[] = [] + const settlements: string[] = [] + const result = await runSupervisedShellProcess({ + ...buildShellInvocation(candidate, 'printf "%s\\n" "你好 \' quote \\" double \\$ literal"\nprintf "%s" "line two"\nexit 7'), + cwd: process.cwd(), + environment: { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8' }, + }, { + onData: data => chunks.push(data), + onSettlement: (record) => { settlements.push(record.settlement) }, + }) + expect(result.exitCode).toBe(7) + expect(Buffer.concat(chunks).toString()).toBe('你好 \' quote " double $ literal\nline two') + expect(settlements.at(-1)).toBe('released') + } + expect(candidates.some(candidate => candidate.id === 'bash' && candidate.available)).toBe(true) + }) +}) diff --git a/apps/buddy/platform/process/sandboxEnvironment.ts b/apps/buddy/platform/process/sandboxEnvironment.ts index c1a3d9837..d9f4d7138 100644 --- a/apps/buddy/platform/process/sandboxEnvironment.ts +++ b/apps/buddy/platform/process/sandboxEnvironment.ts @@ -1,3 +1,4 @@ +import type { ShellDialect } from '../../shared/shell/shellProgram' import { userInfo } from 'node:os' import { delimiter, join, win32 } from 'node:path' import process from 'node:process' @@ -6,7 +7,7 @@ import { createToolchainEnvironment } from './toolchainEnvironment' export const SANDBOX_LOOPBACK_NO_PROXY = 'localhost,127.0.0.1,::1' -export function createWindowsSandboxEnvironment(source: NodeJS.ProcessEnv, options: { shell: string, systemRoot: string, privateRoot: string, path: string, proxyUrl?: string }): Record { +export function createWindowsSandboxEnvironment(source: NodeJS.ProcessEnv, options: { shell: string, dialect?: ShellDialect, systemRoot: string, privateRoot: string, path: string, proxyUrl?: string }): Record { return { ...createSandboxEnvironment(source, options.privateRoot, OPERATING_SYSTEM.Windows), SystemRoot: options.systemRoot, @@ -14,8 +15,7 @@ export function createWindowsSandboxEnvironment(source: NodeJS.ProcessEnv, optio ComSpec: win32.join(options.systemRoot, 'System32', 'cmd.exe'), PATHEXT: '.COM;.EXE;.BAT;.CMD', PATH: options.path, - PSModulePath: win32.join(win32.dirname(options.shell), 'Modules'), - POWERSHELL_TELEMETRY_OPTOUT: '1', + ...(!options.dialect || options.dialect === 'powershell' ? { PSModulePath: win32.join(win32.dirname(options.shell), 'Modules'), POWERSHELL_TELEMETRY_OPTOUT: '1' } : {}), ...(options.proxyUrl ? { HTTP_PROXY: options.proxyUrl, HTTPS_PROXY: options.proxyUrl, ALL_PROXY: options.proxyUrl, NO_PROXY: SANDBOX_LOOPBACK_NO_PROXY, NODE_USE_ENV_PROXY: '1' } : {}), } } diff --git a/apps/buddy/platform/process/shellPrograms.ts b/apps/buddy/platform/process/shellPrograms.ts new file mode 100644 index 000000000..ea6dfa4fd --- /dev/null +++ b/apps/buddy/platform/process/shellPrograms.ts @@ -0,0 +1,184 @@ +import type { ShellCandidate, ShellCandidateId, ShellProgram } from '../../shared/shell/shellProgram' +import { Buffer } from 'node:buffer' +import { execFile } from 'node:child_process' +import { constants } from 'node:fs' +import { access, realpath, stat } from 'node:fs/promises' +import { delimiter, dirname, isAbsolute, join, win32 } from 'node:path' +import process from 'node:process' +import { promisify } from 'node:util' +import { shellCandidateIdSchema, shellProgramSchema } from '../../shared/shell/shellProgram' +import { createToolchainEnvironment } from './toolchainEnvironment' + +const executeFile = promisify(execFile) +const definitions: Record = { + 'bash': { label: 'Bash', dialect: 'bash', name: 'bash' }, + 'git-bash': { label: 'Git Bash', dialect: 'bash', name: 'bash' }, + 'zsh': { label: 'Zsh', dialect: 'zsh', name: 'zsh' }, + 'sh': { label: 'POSIX sh', dialect: 'sh', name: 'sh' }, + 'powershell7': { label: 'PowerShell 7+', dialect: 'powershell', name: 'pwsh' }, + 'powershell51': { label: 'Windows PowerShell 5.1', dialect: 'powershell', name: 'powershell' }, + 'cmd': { label: 'Command Prompt', dialect: 'cmd', name: 'cmd' }, +} + +export function buildShellInvocation(program: ShellProgram, command: string): { shell: string, args: string[], command: string } { + shellProgramSchema.parse({ dialect: program.dialect, executable: program.executable }) + if (!command || command.includes('\0')) + throw new Error('Invalid shell command') + let args: string[] + switch (program.dialect) { + case 'bash': + args = ['--noprofile', '--norc', '-c'] + break + case 'zsh': + args = ['-f', '-c'] + break + case 'sh': + args = ['-c'] + break + case 'powershell': + args = ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand'] + command = Buffer.from(command, 'utf16le').toString('base64') + break + case 'cmd': + if (/[\r\n]/.test(command)) + throw new Error('CMD_MULTILINE_UNSUPPORTED: use a single-line command or select another shell') + args = ['/d', '/s', '/c'] + break + } + return { shell: program.executable, args, command } +} + +export function createShellProgramEnvironment(program: ShellProgram, source: NodeJS.ProcessEnv, platform: NodeJS.Platform = process.platform): NodeJS.ProcessEnv { + const forbidden = new Set(['BASH_ENV', 'ENV', 'ZDOTDIR', 'SHELLOPTS', 'BASHOPTS']) + const environment: NodeJS.ProcessEnv = {} + for (const [name, value] of Object.entries(source)) { + const key = platform === 'win32' ? name.toUpperCase() : name + if (!forbidden.has(key) && !key.startsWith('BASH_FUNC_') && !value?.startsWith('()')) + environment[key] = value + } + if (platform === 'win32' && ['bash', 'zsh', 'sh'].includes(program.dialect)) { + const roots = windowsShellRuntimeDirectories(program.executable) + environment.PATH = [...new Set([...roots, ...(environment.PATH ?? '').split(';')].filter(Boolean))].join(';') + } + return environment +} + +export function windowsShellRuntimeDirectories(executable: string): string[] { + const directory = win32.dirname(executable) + const parent = win32.dirname(directory) + const root = win32.basename(parent).toLowerCase() === 'usr' ? win32.dirname(parent) : parent + return [...new Set([directory, win32.join(root, 'usr', 'bin'), win32.join(root, 'mingw64', 'bin')])] +} + +export async function detectShellPrograms(options: { configured?: Partial>, signal?: AbortSignal } = {}): Promise { + options.signal?.throwIfAborted() + return Promise.all(shellCandidateIdSchema.options.map(async (id): Promise => { + const definition = definitions[id] + const unavailable = (reason: string, executable = ''): ShellCandidate => ({ id, label: definition.label, dialect: definition.dialect, executable, version: '', available: false, reason }) + const configured = options.configured?.[id] + if (configured !== undefined && (!isAbsolute(configured) || configured.startsWith('\\\\') || configured.includes('\0'))) + return unavailable('The configured executable must be an absolute local path') + if (process.platform !== 'win32' && ['git-bash', 'powershell51', 'cmd'].includes(id)) + return unavailable('Not supported on this operating system') + const candidates = configured !== undefined ? [configured] : candidatePaths(id) + for (const path of candidates) { + options.signal?.throwIfAborted() + try { + if (!await usableExecutable(path, definition.dialect)) + continue + const version = await probe(path, id, options.signal) + return { id, label: definition.label, dialect: definition.dialect, executable: path, version, available: true } + } + catch { + options.signal?.throwIfAborted() + return unavailable('The executable failed its bounded version probe', path) + } + } + return unavailable('No usable local installation was found') + })) +} + +function candidatePaths(id: ShellCandidateId): string[] { + const windows = process.platform === 'win32' + const env = Object.fromEntries(Object.entries(process.env).map(([key, value]) => [key.toUpperCase(), value])) + const paths: string[] = [] + if (windows) { + if (id === 'git-bash' || id === 'bash') { + for (const root of [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA && win32.join(env.LOCALAPPDATA, 'Programs')]) { + if (root) + paths.push(win32.join(root, 'Git', 'bin', 'bash.exe'), win32.join(root, 'Git', 'usr', 'bin', 'bash.exe')) + } + } + if (id === 'powershell7' && env.PROGRAMFILES) + paths.push(win32.join(env.PROGRAMFILES, 'PowerShell', '7', 'pwsh.exe')) + if (env.SYSTEMROOT && id === 'powershell51') + paths.push(win32.join(env.SYSTEMROOT, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')) + if (env.SYSTEMROOT && id === 'cmd') + paths.push(win32.join(env.SYSTEMROOT, 'System32', 'cmd.exe')) + } + else { + paths.push(...['/usr/local/bin', '/usr/bin', '/bin', '/opt/homebrew/bin', '/usr/local/microsoft/powershell/7', '/opt/microsoft/powershell/7'].map(root => join(root, definitions[id].name))) + } + for (const root of (env.PATH ?? '').split(delimiter).slice(0, 128)) { + const normalized = root.replace(/^"|"$/g, '') + if (isAbsolute(normalized)) + paths.push(join(normalized, definitions[id].name + (windows ? '.exe' : ''))) + } + return [...new Set(paths)] +} + +async function usableExecutable(path: string, dialect: ShellProgram['dialect']): Promise { + try { + if (!(await stat(path)).isFile()) + return false + await access(path, process.platform === 'win32' ? constants.F_OK : constants.X_OK) + if (process.platform === 'win32') { + const canonical = await realpath(path) + if (canonical.startsWith('\\\\') || /\\(?:WindowsApps|System32|SysWOW64|Sysnative)\\(?:bash|sh|zsh)\.exe$/i.test(canonical)) + return false + if (['bash', 'sh', 'zsh'].includes(dialect)) { + const root = dirname(canonical) + const runtimes = [join(root, 'msys-2.0.dll'), join(root, '..', 'usr', 'bin', 'msys-2.0.dll')] + if (!(await Promise.all(runtimes.map(file => stat(file).then(info => info.isFile(), () => false)))).some(Boolean)) + return false + } + } + return true + } + catch { + return false + } +} + +async function probe(executable: string, id: ShellCandidateId, signal?: AbortSignal): Promise { + const { dialect } = definitions[id] + let args: string[] + if (dialect === 'powershell') + args = ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '[Console]::Write($PSVersionTable.PSVersion.ToString())'] + else if (dialect === 'cmd') + args = ['/d', '/s', '/c', 'ver'] + else if (dialect === 'sh') + args = ['-c', 'printf "LEXORA_POSIX_SH"'] + else + args = dialect === 'bash' ? ['--noprofile', '--norc', '--version'] : ['-f', '--version'] + const environment = createToolchainEnvironment(process.env, process.platform) + for (const name of ['SystemRoot', 'WINDIR', 'HOME', 'USERPROFILE', 'TEMP', 'TMP', 'PATH']) { + const value = Object.entries(process.env).find(([key]) => key.toUpperCase() === name.toUpperCase())?.[1] + if (value) + environment[name] = value + } + const { stdout } = await executeFile(executable, args, { env: environment, signal, timeout: 3000, killSignal: 'SIGKILL', maxBuffer: 8192, windowsHide: true, encoding: 'utf8' }) + const version = stdout.trim() + const valid = dialect === 'bash' + ? /GNU bash, version \d/i.test(version) + : dialect === 'zsh' + ? /^zsh \d/i.test(version) + : dialect === 'sh' + ? version === 'LEXORA_POSIX_SH' + : dialect === 'cmd' + ? /\d+\.\d+/.test(version) + : id === 'powershell51' ? /^5\.1\./.test(version) : Number.parseInt(version) >= 7 + if (!valid) + throw new Error('Unexpected shell version') + return dialect === 'sh' ? 'POSIX sh (version not reported)' : version.split(/[\r\n]/)[0]!.slice(0, 256) +} diff --git a/apps/buddy/platform/process/windowsSandboxProcess.ts b/apps/buddy/platform/process/windowsSandboxProcess.ts index c14ce4097..dda79b262 100644 --- a/apps/buddy/platform/process/windowsSandboxProcess.ts +++ b/apps/buddy/platform/process/windowsSandboxProcess.ts @@ -1,5 +1,6 @@ import type { Buffer } from 'node:buffer' import type { SandboxProcessState } from '../../shared/permissions/sandboxLifecycle' +import type { ShellDialect } from '../../shared/shell/shellProgram' import type { OwnedProcessSupervision } from './supervisedShellProcess' import { spawn } from 'node:child_process' import { randomUUID } from 'node:crypto' @@ -14,6 +15,7 @@ export interface WindowsSandboxRequest { command: string cwd: string shell: string + dialect?: ShellDialect privateRoot: string grants: readonly { path: string, access: 'read' | 'write' | 'denyRead' | 'denyWrite', device: string, inode: string, inherit?: boolean, exceptions?: string[] }[] denyReadScope?: string diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/api.d.ts b/apps/buddy/service/resources/skills/plugin-creator/references/api.d.ts index 5aec33815..7f7f5ae77 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/references/api.d.ts +++ b/apps/buddy/service/resources/skills/plugin-creator/references/api.d.ts @@ -120,15 +120,27 @@ export interface ConditionApi { register: (id: string, evaluate: (context: ConditionContext, params: ReadonlyJsonObject) => boolean | ConditionResult | Promise) => Disposable invalidate: (input?: { condition?: string, scopeKey?: string }) => Promise } +export type ShellCandidateId = 'bash' | 'git-bash' | 'zsh' | 'sh' | 'powershell7' | 'powershell51' | 'cmd' +export interface ShellCandidate { + id: ShellCandidateId + label: string + dialect: 'bash' | 'zsh' | 'sh' | 'powershell' | 'cmd' + executable: string + version: string + available: boolean + reason?: string +} + export interface ExtensionContext { readonly themes: ThemeApi - readonly extension: { readonly id: string, readonly version: string, readonly apiVersion: 1 | 2 | 3 } + readonly extension: { readonly id: string, readonly version: string, readonly apiVersion: 1 | 2 | 3 | 4 } readonly events: EventSubscriber readonly configuration: { get: () => Promise> onChange: (listener: (configuration: Readonly>) => void | Promise) => Disposable } readonly conditions: ConditionApi + readonly shells: { detect: () => Promise } readonly agent: { registerTool: (id: string, execute: (input: Record, context: AgentToolContext) => Json | void | Promise) => Disposable registerAction: (id: string, execute: (context: AgentActionContext) => AgentActionResult | Promise) => Disposable @@ -219,7 +231,7 @@ export interface ViewContext { } | null readonly environment: ViewEnvironment onEnvironmentChange: (listener: (environment: ViewContext['environment']) => void) => Disposable - readonly apiVersion: 1 | 2 | 3 + readonly apiVersion: 1 | 2 | 3 | 4 readonly resource: Resource | null readonly state: Json readonly stateVersion: number diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/capabilities.md b/apps/buddy/service/resources/skills/plugin-creator/references/capabilities.md index a9aab5496..9a5d94f27 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/references/capabilities.md +++ b/apps/buddy/service/resources/skills/plugin-creator/references/capabilities.md @@ -4,6 +4,7 @@ | 用户需要 | 清单与入口 | 状态和边界 | | --- | --- | --- | +| 检测并选择已有本机命令解释器 | API 4;contributes.shells、permissions.shells、原生 settings,见 [命令解释器](shells.md) | 用户启用后供模型选择;宿主执行,插件只能 detect(),不能运行命令或写解释器路径 | | 由主模型主动调用插件、请求模型或修改任务标题 | API 3;contributes.agent 和 agent/models/tasks 权限,见 [Agent 与设置](agent-settings.md) | 工具入口绑定原任务与本轮模型,不导出凭据,不覆盖手动标题 | | 输入提交、对话成功完成或任务菜单点击后执行 | API 3;contributes.agent.actions,见 [任务动作事件](events.md#任务动作事件) 与 [Agent 与设置](agent-settings.md) | 独立调用绑定原任务;正文需要 taskMessages 权限,标题更新由宿主保护并发修改 | | 在原生设置页追加模块、分组或单项 | API 3;contributes.settings,见 [Agent 与设置](agent-settings.md) | 宿主保存配置并渲染控件;插件读取 configuration,不另存设置 | diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md b/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md index 549705cba..512f5259a 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md +++ b/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md @@ -37,6 +37,7 @@ | `navigation` | 单个对象,必填 `title`、`view`,引用自己的无资源视图 | | `agent` | API 3;指令、工具、任务动作和可选的 enabledWhen,见 [Agent 与设置](agent-settings.md) | | `settings` | API 3;声明 modules/groups/items,由宿主渲染并保存,见 [Agent 与设置](agent-settings.md) | +| `shells` | API 4;固定本机解释器候选与原生设置映射;可选 settingsGroup 将插件自有组渲染为一行一个 Shell 的原生表格,文件选择仅限原生设置,需独立 shells 权限,见 [命令解释器](shells.md) | 插件 ID 使用小写字母开头的两段名称,中间以 `.` 分隔,每段可含小写字母、数字与 `-`;贡献 ID 在插件 ID 后追加由小写字母、数字、`.`、`-` 组成的后缀。声明文件路径使用包内相对路径,`.ts` 文件名直接指向源码,编译工具负责产出运行文件。 diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/shells.md b/apps/buddy/service/resources/skills/plugin-creator/references/shells.md new file mode 100644 index 000000000..a788fae9f --- /dev/null +++ b/apps/buddy/service/resources/skills/plugin-creator/references/shells.md @@ -0,0 +1,62 @@ +# 本机命令解释器 + +此能力要求 `apiVersion: 4`、`engines.lexora: ">=0.14.0 <1.0.0"` 和独立的 `permissions.shells: true`。API 1–3 插件仍可使用原有能力;只增加版本范围不能让旧 API 3 宿主识别这项能力。 + +插件声明候选和设置映射,Lexora 检测并执行;插件不获得进程、任意命令或配置写权限。只支持本机已有解释器的非交互命令,不安装解释器,不使用 WSL、PTY 或交互终端。 + +## 声明 + +`contributes.shells.candidates` 是最多 7 项的数组: + +```json +{ + "id": "bash", + "enabled": "bashEnabled", + "executable": "bashExecutable", + "priority": "bashPriority" +} +``` + +- id 固定为 `bash`、`git-bash`、`zsh`、`sh`、`powershell7`、`powershell51`、`cmd`,不得重复。Git Bash 使用 bash 方言。 +- 其余三个字段引用本插件原生 settings 的不同 key。enabled 必须为默认 false 的 boolean;executable 必须为默认空字符串的 string;priority 必须为 number,default 0、min 0、max 100。不能在清单中放置可执行文件默认路径。 +- 空路径表示自动检测;自定义绝对本机路径由用户在原生设置中通过系统文件选择器指定。表格中的路径只读,用户可主动重新检测、选择文件或恢复自动,无需手填。插件 configuration 仅可读取,不可用 storage 或命令参数替代。 +- 只有用户启用的候选可供模型选择。priority 0 不参与优先选择;正数越小越优先,同数按声明顺序。全部为 0 时保留平台默认。 +- 候选不适用于当前系统、缺失或检测失败时保留不可用结果与原因,不自动安装或切换解释器。 + +模型通过统一 `lexora_shell` 工具选择 `/`;`system` 表示原有平台解释器。未设置正优先级时默认仍为 system。检测成功不代表所有平台均能受控执行:当前插件候选执行接入 Linux/Windows,macOS 尚不开放。宿主 sh/zsh/CMD 命令会保守请求审批;CMD 只接受单行,Zsh 仍可能读取系统 zshenv。 + +## 原生设置表格 + +可选的 `contributes.shells.settingsGroup` 引用本插件声明的设置组 ID;所有候选引用的 enabled、executable、priority 设置必须属于这个组。宿主将该组渲染为原生表格,每个候选一行,包含启用、检测状态、版本、只读路径、优先级和路径操作,无需插件自定义视图或命令。 + +建议将该组注册到内置 `settings.runtime` 模块: + +```json +{ + "shells": { + "settingsGroup": ".execution", + "candidates": [{ "id": "bash", "enabled": "bashEnabled", "executable": "bashExecutable", "priority": "bashPriority" }] + }, + "settings": { + "modules": [], + "groups": [{ "id": ".execution", "module": "settings.runtime", "title": "命令解释器" }], + "items": [ + { "id": ".bash.enabled", "key": "bashEnabled", "group": ".execution", "title": "启用", "type": "boolean", "default": false }, + { "id": ".bash.executable", "key": "bashExecutable", "group": ".execution", "title": "路径", "type": "string", "default": "" }, + { "id": ".bash.priority", "key": "bashPriority", "group": ".execution", "title": "优先级", "type": "number", "default": 0, "min": 0, "max": 100 } + ] + } +} +``` + +上述对象是 `contributes` 的内容,`` 替换为清单中的插件 ID。无需自定义交互时可只保留清单和资源,不声明 entry、commands 或 views。 + +文件选择仅开放给可信的原生设置窗口,不属于插件 SDK。选择前验证启用、权限和候选声明;返回前复验包版本与有效性。选择本身不执行程序、不创建文件资源授权、不写入配置;取消返回空结果。原生设置通过既有配置保存链保存所选路径或空字符串(恢复自动),保留原设置 key 和用户值。 + +## 检测 + +插件 SDK 仅宿主入口提供 `await context.shells.detect()`,无任何参数;原生设置也可主动刷新检测。它返回声明候选的 `{id,label,dialect,executable,version,available,reason?}[]`,包含未启用和不可用项。版本和路径由宿主检测,插件不能传命令、路径或探针参数。检测是有界的,相同配置并发合并;一秒内刷新复用结果。 + +SDK 的 Shell 能力仍只有只读 `shells.detect()`,配置仅有 `configuration.get()` 与 `configuration.onChange()`,不提供文件选择或配置写入。特殊自定义视图可经本插件隐藏命令读取检测结果,不直接访问 Shell;使用原生表格时无需重复提供检测视图。缺失时明确提示,不附安装操作或指引。不要申请 agent、network、localResources 或 inference 权限来绕开此边界。 + +配置变更影响下一轮任务;禁用、卸载和应用插件更新撤回旧贡献,正在运行的命令不更换解释器。解释器可用不表示获得文件或网络授权,执行仍由核心沿原审批、目录、网络、超时和取消机制处理。 diff --git a/apps/buddy/service/src/BuddyService.ts b/apps/buddy/service/src/BuddyService.ts index 73859905e..2de2e83ba 100644 --- a/apps/buddy/service/src/BuddyService.ts +++ b/apps/buddy/service/src/BuddyService.ts @@ -28,6 +28,7 @@ import { CONVERSATION_CHANGED, conversationTimelineChanged } from '../../shared/ import { executionsChanged } from '../../shared/executions/executionPresentationApi' import { extensionActionRpc } from '../../shared/extensions/extensionActionApi' import { extensionAgentRpc } from '../../shared/extensions/extensionAgent' +import { extensionShellRpc } from '../../shared/extensions/extensionShell' import { ServiceHost } from '../../shared/lifecycle/ServiceHost' import { ServiceLifecycleSource } from '../../shared/lifecycle/ServiceLifecycleSource' import { webSettingsChanged } from '../../shared/network/webApi' @@ -148,13 +149,14 @@ import { ProviderDependents } from './providers/ProviderDependents' import { registerProviderRpc } from './providers/registerProviderRpc' import { resolveInteractiveModelSelection } from './providers/resolveInteractiveModelSelection' import { BuddyServiceError, registerRuntimeRequest } from './rpc/runtimeRequest' - import { registerRunRpc } from './runs/registerRunRpc' + import { RunContinuityService } from './runs/RunContinuityService' import { RunLifecycleService } from './runs/RunLifecycleService' import { RunRecoveryService } from './runs/RunRecoveryService' import { observeSandboxClient } from './sandbox/observeSandboxClient' import { ShellSandboxClient } from './sandbox/ShellSandboxClient' +import { ExtensionShellRegistry } from './sandbox/ShellSelection' import { observeSkillDiagnostics, observeSkillNotifications } from './skills/observeSkillEvents' import { registerSkillServiceRpc, @@ -613,6 +615,8 @@ export async function startBuddyService( } }) defer(options.rpc.onNotification((method) => { + if (method === extensionShellRpc.changed) + void sessions.invalidateAll().catch(() => record({ event: 'shells.sessions_invalidation.failed', level: 'warn', errorCode: 'EXTENSION_INVALIDATION_FAILED' })) if (method === extensionAgentRpc.changed) void sessions.invalidateMode('interactive').catch(() => record({ event: 'plugins.sessions_invalidation.failed', level: 'warn', errorCode: 'EXTENSION_INVALIDATION_FAILED' })) })) @@ -872,6 +876,7 @@ export async function startBuddyService( return service }) const sessionExtensionServices: BuddySessionExtensionServices = { + shells: new ExtensionShellRegistry(options.rpc), networkPermissions: new NetworkPermissionsClient(options.rpc), fileResources, delegation: { pending: (...arguments_) => delegation.pending(...arguments_), execute: (...arguments_) => delegation.execute(...arguments_), assertShellExecutionAllowed: runId => delegation.assertShellExecutionAllowed(runId) }, @@ -1021,7 +1026,7 @@ export async function startBuddyService( }) delegation = new ExecutionDelegationService({ readResult: runId => readExecutionResult(options.eventLog, runId), - readToolOptions: signal => readBuddyToolOptions(sessionExtensionServices.createCapabilities, agentDirectory, signal), + readToolOptions: signal => readBuddyToolOptions(sessionExtensionServices.createCapabilities, agentDirectory, signal, sessionExtensionServices.shells), actorTargets: (rootRunId) => { const run = runs.findById(rootRunId) const content = run ? buddyUserMessageContentV1Schema.safeParse(conversationHistory.repository.findMessageById(run.triggeringMessageId)?.content) : null @@ -1513,7 +1518,7 @@ export async function startBuddyService( service: contextUsageService, }), ) - register(registerBuddiesRpc(options.rpc, buddies, signal => readBuddyToolOptions(sessionExtensionServices.createCapabilities, agentDirectory, signal))) + register(registerBuddiesRpc(options.rpc, buddies, signal => readBuddyToolOptions(sessionExtensionServices.createCapabilities, agentDirectory, signal, sessionExtensionServices.shells))) register(registerTaskMarkRpc(options.rpc, taskMarks, taskAttention)) register(registerConversationTreeRpc({ database: options.database, diff --git a/apps/buddy/service/src/__tests__/buddyService.integration.spec.ts b/apps/buddy/service/src/__tests__/buddyService.integration.spec.ts index 578e36f72..955cc761a 100644 --- a/apps/buddy/service/src/__tests__/buddyService.integration.spec.ts +++ b/apps/buddy/service/src/__tests__/buddyService.integration.spec.ts @@ -1891,7 +1891,7 @@ function createRuntimeRpcHarness( url?: string value?: unknown } - if (method === 'extensions.agent.list') + if (method === 'extensions.agent.list' || method === 'host.extensions.shells.list') return Promise.resolve([]) if (method === 'host.networkPermissions.get') return Promise.resolve(structuredClone(DEFAULT_NETWORK_PERMISSIONS)) diff --git a/apps/buddy/service/src/__tests__/multiShell.integration.spec.ts b/apps/buddy/service/src/__tests__/multiShell.integration.spec.ts new file mode 100644 index 000000000..222084e3c --- /dev/null +++ b/apps/buddy/service/src/__tests__/multiShell.integration.spec.ts @@ -0,0 +1,41 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import process from 'node:process' +import { expect, it } from 'vitest' +import { createConcurrencyRuntime, tool } from './mutationConcurrencyFixture' + +it.skipIf(process.platform !== 'linux')('exposes one command entry across real child sessions and never replays a failed selected Shell command', async () => { + let marker = '' + const shell = 'tests.multi-shell/bash' + const runtime = await createConcurrencyRuntime({ + fullAccess: true, + shells: [{ + extensionId: 'tests.multi-shell', + revision: 'package-1', + configurationRevision: 'configuration-1', + preferred: ['bash'], + candidates: [{ id: 'bash', label: 'Bash', dialect: 'bash', executable: '/bin/bash', version: '5', available: true }], + }], + steps: role => role === 'root' ? [tool('selected-shell', 'lexora_shell', { shell, command: `printf 'once\\n' >> '${marker.replaceAll('\'', '\'\\\'\'')}'; exit 7`, timeout: 10 })] : [], + }) + try { + marker = join(runtime.workspace, 'executed-once.txt') + runtime.start() + await runtime.complete() + const completed = runtime.events.filter(event => event.type === 'tool.completed' && (event.payload as { toolCallId?: string }).toolCallId === 'selected-shell') + expect(completed.map(event => event.payload)).toMatchObject([{ isError: true, presentation: { exitCode: 7 } }]) + expect(await readFile(marker, 'utf8')).toBe('once\n') + for (const request of runtime.requests) { + const names = request.payload.tools?.map(tool => tool.function.name) ?? [] + expect(names).toContain('lexora_shell') + expect(names).not.toContain('bash') + expect(names).not.toContain('powershell') + } + const completions = runtime.events.filter(event => event.type === 'tool.completed' && (event.payload as { toolCallId?: string }).toolCallId === 'selected-shell') + expect(completions).toHaveLength(1) + expect(completions[0]?.payload).toMatchObject({ isError: true }) + } + finally { + await runtime.dispose() + } +}, 30000) diff --git a/apps/buddy/service/src/__tests__/mutationConcurrencyFixture.ts b/apps/buddy/service/src/__tests__/mutationConcurrencyFixture.ts index 787c48d3f..e598c6e3a 100644 --- a/apps/buddy/service/src/__tests__/mutationConcurrencyFixture.ts +++ b/apps/buddy/service/src/__tests__/mutationConcurrencyFixture.ts @@ -1,6 +1,7 @@ import type { DatabaseSync } from 'node:sqlite' import type { LocalBuddy } from '../../../shared/buddies/buddyApi' import type { LocalTurnStart } from '../../../shared/conversation/chatApi' +import type { ExtensionShellDescriptor } from '../../../shared/extensions/extensionShell' import type { BuddyServiceRpcServer } from '../rpc/BuddyServiceRpcServer' import { Buffer } from 'node:buffer' import { createHash } from 'node:crypto' @@ -63,7 +64,7 @@ function requestRole(payload: Payload): Role { return task } -function rpcHarness() { +function rpcHarness(shells: readonly ExtensionShellDescriptor[]) { const handlers = new Map[1]>() const rpc = { close() {}, @@ -76,6 +77,7 @@ function rpcHarness() { async request(method: string) { switch (method) { case 'extensions.agent.list': return [] + case 'host.extensions.shells.list': return shells case 'host.images.preferences': return {} case 'host.networkPermissions.get': return { defaults: { access: 'ask', download: 'ask', upload: 'ask' }, rules: [] } case 'host.runtimePreferences.get': return { cacheWarming: 'off', codemode: false, modelRetryLimit: 0 } @@ -100,6 +102,7 @@ export async function createConcurrencyRuntime(options: { steps: (role: Role) => ToolCall[] readOnly?: boolean fullAccess?: boolean + shells?: readonly ExtensionShellDescriptor[] prepare?: (database: DatabaseSync, buddyHome: string) => Promise }) { const directory = await realpath(await mkdtemp(join(tmpdir(), 'lexora-mutation-tree-'))) @@ -110,7 +113,7 @@ export async function createConcurrencyRuntime(options: { const eventLog = createRunEventLog({ conversationsDirectory: join(buddyHome, 'conversations'), database }) const events: Awaited> = [] const subscription = eventLog.onDidCommit(event => events.push(event)) - const harness = rpcHarness() + const harness = rpcHarness(options.shells ?? []) const ready = deferred() const begin = deferred() const requests: { role: Role, payload: Payload }[] = [] @@ -212,6 +215,7 @@ export async function createConcurrencyRuntime(options: { const root = await harness.invoke('buddies.get', { buddyId: 'default' }) as LocalBuddy npcId = npc.id actorId = actor.id + const shellTool = options.shells?.length ? ['builtin', 'shell', 'lexora_shell'] : ['builtin', 'core', process.platform === 'win32' ? 'powershell' : 'bash'] for (const [buddy, role] of [[npc, 'NPC'], [actor, 'ACTOR'], [root, 'ROOT']] as const) { await harness.invoke('buddies.update', { buddyId: buddy.id, @@ -221,7 +225,7 @@ export async function createConcurrencyRuntime(options: { instructions: `MUTATION_ROLE_${role}`, model: role === 'ACTOR' ? { modelId: 'mutation-model', providerId: 'mutation-local', reasoning: null, serviceTier: null } : null, skills: { mode: 'none' }, - tools: { mode: 'selected', ids: ['read', 'write', 'edit', ...(options.fullAccess ? [process.platform === 'win32' ? 'powershell' : 'bash'] : [])].map(name => JSON.stringify(['builtin', 'core', name])) }, + tools: { mode: 'selected', ids: [...['read', 'write', 'edit'].map(name => JSON.stringify(['builtin', 'core', name])), ...options.fullAccess ? [JSON.stringify(shellTool)] : []] }, limits: { fileAccess: options.readOnly && role === 'ROOT' ? 'read_only' : 'inherit' }, npcIds: role === 'NPC' ? [] : [npcId], }, diff --git a/apps/buddy/service/src/agent/events/PiEventBridge.ts b/apps/buddy/service/src/agent/events/PiEventBridge.ts index 07d6cbafa..c667df37b 100644 --- a/apps/buddy/service/src/agent/events/PiEventBridge.ts +++ b/apps/buddy/service/src/agent/events/PiEventBridge.ts @@ -7,7 +7,7 @@ import type { BuddySessionEventSource } from '../sessions/ReusableBuddySession' import type { BuddyProjectedEvent } from './projectPiEvent' import { safeDiagnosticReporter } from '../../../../shared/diagnostics/applicationDiagnostic' import { RunEventLogUnavailableError } from '../../events/RunEventFailure' -import { isPiShellToolName } from '../extensions/piBuiltinTools' +import { isShellToolName } from '../extensions/piBuiltinTools' import { BufferedRunEventWriter } from './BufferedRunEventWriter' import { PiApplicationObserver } from './PiApplicationObserver' import { @@ -390,7 +390,7 @@ class ActivePiEventChannel implements PiCompactionEventChannel, PiTurnEventChann function isTerminalToolExecutionUpdate( event: AgentSessionEvent, ): event is PiToolExecutionUpdateEvent { - return event.type === 'tool_execution_update' && (isPiShellToolName(event.toolName) || event.toolName === 'lexora_host_shell') + return event.type === 'tool_execution_update' && (isShellToolName(event.toolName) || event.toolName === 'lexora_host_shell') } async function settledError(promise: Promise): Promise { diff --git a/apps/buddy/service/src/agent/events/toolPresentation.ts b/apps/buddy/service/src/agent/events/toolPresentation.ts index 91e9c5b7e..a29f6dc4e 100644 --- a/apps/buddy/service/src/agent/events/toolPresentation.ts +++ b/apps/buddy/service/src/agent/events/toolPresentation.ts @@ -29,7 +29,7 @@ import { createPetToolPresentation } from '../../pet/petToolContract' import { createSystemToolPresentation } from '../../system/systemToolContract' import { createWebToolPresentation } from '../../web/webToolPresentation' import { TOOL_SEARCH_NAME } from '../extensions/discovery/toolDiscoveryContract' -import { isPiShellToolName } from '../extensions/piBuiltinTools' +import { isShellToolName } from '../extensions/piBuiltinTools' export type { CreateBuddyToolPresentationInput } from '../../events/toolPresentationSupport' @@ -67,7 +67,7 @@ function createPiToolPresentation( const preview = boundedToolPreview(output) const description = readOptionalString(arguments_, 'description') - if (isPiShellToolName(input.toolName) || input.toolName === 'lexora_host_shell') { + if (isShellToolName(input.toolName) || input.toolName === 'lexora_host_shell') { return { card: 'terminal', command: redactShellCommand(readString(arguments_, 'command')), diff --git a/apps/buddy/service/src/agent/execution/ExecutionDelegationService.ts b/apps/buddy/service/src/agent/execution/ExecutionDelegationService.ts index 94c7681e9..89469ef4c 100644 --- a/apps/buddy/service/src/agent/execution/ExecutionDelegationService.ts +++ b/apps/buddy/service/src/agent/execution/ExecutionDelegationService.ts @@ -15,7 +15,7 @@ import { MAX_BUDDY_MESSAGE_TEXT_LENGTH } from '../../../../shared/conversation/b import { resolveShellExecution } from '../../sandbox/shellExecution' import { ExecutionAdmissionError, executionFence, hashChildAdmissionRequest } from '../../storage/executionRepository' import { getRoleToolDenial } from '../extensions/discovery/SessionToolScope' -import { isPiShellToolName } from '../extensions/piBuiltinTools' +import { isPiShellToolName, isShellToolName } from '../extensions/piBuiltinTools' import { BuddySessionStorageError } from '../sessions/BuddySessionErrors' import { readExecutionMailboxEntries } from './executionMailboxEntries' @@ -205,8 +205,10 @@ export class ExecutionDelegationService { const readOnly = permissions.executionProfile === 'read_only' || source.fileAccessCeiling === 'read_only' || source.run.actorSnapshot?.settings.limits.fileAccess === 'read_only' || buddy.settings.limits.fileAccess === 'read_only' const executionProfile = readOnly ? 'read_only' : permissions.executionProfile - const tools = toolOptions.filter(tool => !getRoleToolDenial(tool.name, tool, selected, readOnly ? 'read_only' : 'inherit') - && (currentPlatform.hostShellSupervision || !isPiShellToolName(tool.name))) + const roleTools = toolOptions.filter(tool => !getRoleToolDenial(tool.name, tool, selected, readOnly ? 'read_only' : 'inherit') + && (currentPlatform.hostShellSupervision || !isShellToolName(tool.name))) + const unifiedShell = roleTools.some(tool => tool.name === 'lexora_shell') + const tools = unifiedShell ? roleTools.filter(tool => !isPiShellToolName(tool.name)) : roleTools const shell = resolveShellExecution(executionProfile) return { ...target, @@ -214,7 +216,7 @@ export class ExecutionDelegationService { tools: tools.map(tool => tool.name), unavailableToolIds: [...selected ?? []].filter(id => !toolOptions.some(tool => tool.id === id)), executionProfile, - shell: tools.some(tool => tool.name === shell.dialect) ? { tool: shell.dialect, boundary: shell.boundary, readOnly: shell.readOnly } : null, + shell: unifiedShell || tools.some(tool => tool.name === shell.dialect) ? { tool: unifiedShell ? 'lexora_shell' : shell.dialect, boundary: shell.boundary, readOnly: shell.readOnly } : null, } } return { actors: catalog.actors.map(describe), npcs: catalog.npcs.map(describe) } diff --git a/apps/buddy/service/src/agent/extensions/changeCaptureExtension.ts b/apps/buddy/service/src/agent/extensions/changeCaptureExtension.ts index 504681a36..9c9560fb0 100644 --- a/apps/buddy/service/src/agent/extensions/changeCaptureExtension.ts +++ b/apps/buddy/service/src/agent/extensions/changeCaptureExtension.ts @@ -11,7 +11,7 @@ import { dirname } from 'node:path' import { containsCanonicalPath } from '../../../../platform/filesystem/filePaths' import { resolveFilePath } from '../../../../platform/filesystem/resolveFilePath' import { isToolClassificationFailure } from '../../approvals/toolClassification' -import { isPiShellToolName } from './piBuiltinTools' +import { isShellToolName } from './piBuiltinTools' export type BuddyToolWrapper = (tool: ToolDefinition) => ToolDefinition @@ -210,9 +210,9 @@ function defaultMutationEffect(event: ToolCallEvent, classification: BuddyToolCl throw new Error('VALIDATION_FAILED') return { kind: 'paths', paths: [{ path, mode: event.toolName === 'edit' ? 'existing' : 'create' }] } } - if (event.toolName === 'lexora_host_shell' || (isPiShellToolName(event.toolName) && permit.executionProfile === 'full_access')) + if (event.toolName === 'lexora_host_shell' || (isShellToolName(event.toolName) && permit.executionProfile === 'full_access')) return { kind: 'host', ownedProcess: true } - if (isPiShellToolName(event.toolName)) + if (isShellToolName(event.toolName)) return { kind: 'workspace', ownedProcess: true } if (event.toolName.startsWith('mcp__')) return { kind: 'host' } diff --git a/apps/buddy/service/src/agent/extensions/createBuddySessionExtensions.ts b/apps/buddy/service/src/agent/extensions/createBuddySessionExtensions.ts index 7b33fc9c2..42abb24b4 100644 --- a/apps/buddy/service/src/agent/extensions/createBuddySessionExtensions.ts +++ b/apps/buddy/service/src/agent/extensions/createBuddySessionExtensions.ts @@ -16,6 +16,7 @@ import type { DirectoryGrant } from '../../directories/resolveGrantedPath' import type { FileResourceCoordinator } from '../../files/FileResourceCoordinator' import type { NetworkPermissionsStore } from '../../permissions/NetworkPermissionsClient' import type { ShellSandboxClient } from '../../sandbox/ShellSandboxClient' +import type { ExtensionShellRegistry } from '../../sandbox/ShellSelection' import type { BuddyInputReferenceStore } from '../context/BuddyInputReference' import type { ExecutionDelegationService } from '../execution/ExecutionDelegationService' import type { BuddyCapability, BuddyCapabilityFactory, BuddyCapabilityResourceRevision } from './BuddyCapability' @@ -60,6 +61,7 @@ export interface BuddySessionExtensionServices { recordPermissions?: ApplicationDiagnosticReporter resolveToolExposure?: BuddyToolExposureResolver shellSandbox?: Pick + shells?: Pick } export interface CreateBuddySessionExtensionsOptions { @@ -190,7 +192,9 @@ export async function createBuddySessionExtensions( sessionMode: options.sessionMode, signal: lifetime.signal, })) + const shells = await services.shells?.snapshot(lifetime.signal) capabilities.push(createShellCapability({ + shells, cwd: options.canonicalRoot, execution: resolveShellExecution(getExecutionProfile()), getExecution: () => resolveShellExecution(getExecutionProfile()), diff --git a/apps/buddy/service/src/agent/extensions/discovery/ToolDisclosure.ts b/apps/buddy/service/src/agent/extensions/discovery/ToolDisclosure.ts index 024a3eabf..d3025be0f 100644 --- a/apps/buddy/service/src/agent/extensions/discovery/ToolDisclosure.ts +++ b/apps/buddy/service/src/agent/extensions/discovery/ToolDisclosure.ts @@ -8,6 +8,7 @@ import { getCurrentSystemMessage } from '@earendil-works/pi-ai' import MiniSearch from 'minisearch' import { Emitter } from '../../../../../shared/events/Emitter' import { copyEventSnapshot } from '../../../../../shared/events/eventSnapshot' +import { isPiShellToolName } from '../piBuiltinTools' import { createToolCatalog } from './toolCatalog' import { isToolSearchResult, TOOL_SEARCH_NAME } from './toolDiscoveryContract' @@ -82,7 +83,8 @@ export class ToolDisclosure { constructor(tools: readonly ToolInfo[], baseline: readonly string[], policies: readonly BuddyToolDisclosurePolicy[], resolveExposure?: BuddyToolExposureResolver, scope?: SessionToolScope) { scope?.assertRegistered(tools.map(tool => tool.name)) - this.#tools = new Map([...createToolCatalog(tools, baseline, policies)].filter(([name]) => !scope?.denial(name))) + const unifiedShell = tools.some(tool => tool.name === 'lexora_shell') && !scope?.denial('lexora_shell') + this.#tools = new Map([...createToolCatalog(tools, baseline, policies)].filter(([name]) => !scope?.denial(name) && !(unifiedShell && isPiShellToolName(name)))) this.#resolveExposure = resolveExposure this.#index = new MiniSearch({ idField: 'name', diff --git a/apps/buddy/service/src/agent/extensions/discovery/__tests__/ToolDisclosure.spec.ts b/apps/buddy/service/src/agent/extensions/discovery/__tests__/ToolDisclosure.spec.ts index fbf0932bb..c7523aac7 100644 --- a/apps/buddy/service/src/agent/extensions/discovery/__tests__/ToolDisclosure.spec.ts +++ b/apps/buddy/service/src/agent/extensions/discovery/__tests__/ToolDisclosure.spec.ts @@ -3,6 +3,7 @@ import type { ToolInfo } from '@earendil-works/pi-coding-agent' import type { BuddyToolDisclosurePolicy } from '../toolDiscoveryContract' import { Type } from 'typebox' import { describe, expect, it } from 'vitest' +import { SessionToolScope } from '../SessionToolScope' import { ToolDisclosure } from '../ToolDisclosure' import { TOOL_SEARCH_NAME } from '../toolDiscoveryContract' @@ -19,6 +20,17 @@ function create() { } describe('toolDisclosure', () => { + it('exposes only the unified command tool while retaining roles pinned to legacy shell identities', () => { + const shellTools = ['bash', 'lexora_shell'].map(name => ({ ...tools[0]!, name })) + const policies: BuddyToolDisclosurePolicy[] = [{ source: { kind: 'builtin', id: 'shell', title: 'Shell' }, exposure: 'direct', keywords: '', tools: [{ name: 'lexora_shell' }] }] + const all = new ToolDisclosure(shellTools, ['bash'], policies) + expect(all.active({ model })).toEqual(['lexora_shell']) + expect(all.search({ toolNames: ['bash'] }, { model }).notFound).toEqual(['bash']) + const role = new SessionToolScope(policies, { mode: 'selected', ids: [JSON.stringify(['builtin', 'core', 'bash'])] }) + expect(new ToolDisclosure(shellTools, ['bash'], policies, undefined, role).active({ model })).toEqual(['bash']) + expect(new ToolDisclosure([shellTools[0]!], ['bash'], []).active({ model })).toEqual(['bash']) + }) + it('resolves historical MCP names without granting hidden or script-only tools direct exposure', () => { const name = 'mcp__calendar__events' const alias = 'mcp__old_hash__events_hash' diff --git a/apps/buddy/service/src/agent/extensions/piBuiltinTools.ts b/apps/buddy/service/src/agent/extensions/piBuiltinTools.ts index 64d6b4f05..f0d398160 100644 --- a/apps/buddy/service/src/agent/extensions/piBuiltinTools.ts +++ b/apps/buddy/service/src/agent/extensions/piBuiltinTools.ts @@ -30,6 +30,10 @@ export function getActivePiBuiltinToolNames( return [...PI_FILE_TOOL_NAMES, getPiShellToolName(platform)] } +export function isShellToolName(toolName: string): boolean { + return toolName === 'lexora_shell' || isPiShellToolName(toolName) +} + export function isPiShellToolName(toolName: string): toolName is PiShellToolName { return (PI_SHELL_TOOL_NAMES as readonly string[]).includes(toolName) } diff --git a/apps/buddy/service/src/agent/resources/createBuddySystemPrompt.ts b/apps/buddy/service/src/agent/resources/createBuddySystemPrompt.ts index 600efea3e..eb46cec25 100644 --- a/apps/buddy/service/src/agent/resources/createBuddySystemPrompt.ts +++ b/apps/buddy/service/src/agent/resources/createBuddySystemPrompt.ts @@ -48,21 +48,22 @@ export function createBuddySystemPrompt(options: CreateBuddySystemPromptOptions) export function createBuddyExecutionPrompt(executionProfile: BuddyExecutionProfile, platform?: NodeJS.Platform, activeToolNames?: readonly string[]): string { const execution = resolveShellExecution(executionProfile, platform) - const prompt = createExecutionProfilePrompt(executionProfile, execution.dialect, execution.boundary === 'sandbox', activeToolNames) - return execution.boundary === 'sandbox' && execution.backend === SHELL_SANDBOX_BACKEND.Windows && (activeToolNames?.includes(execution.dialect) ?? true) + const shellTool = activeToolNames?.includes('lexora_shell') ? 'lexora_shell' : execution.dialect + const prompt = createExecutionProfilePrompt(executionProfile, shellTool, execution.boundary === 'sandbox', activeToolNames) + return execution.boundary === 'sandbox' && execution.backend === SHELL_SANDBOX_BACKEND.Windows && (activeToolNames?.includes(shellTool) ?? true) ? `${prompt}\n${WINDOWS_SANDBOX_FILESYSTEM_LIMITS}` : prompt } function createExecutionProfilePrompt( executionProfile: BuddyExecutionProfile, - shellTool: 'bash' | 'powershell', + shellTool: 'bash' | 'powershell' | 'lexora_shell', isolatedShell: boolean, activeToolNames?: readonly string[], ): string { const hasTool = (name: string) => activeToolNames?.includes(name) ?? true const hasShell = hasTool(shellTool) - const shellName = shellTool === 'powershell' ? 'PowerShell' : 'bash' + const shellName = shellTool === 'powershell' ? 'PowerShell' : shellTool const inspection = [ hasShell ? `Use Pi built-in tools, including ${shellName}, for general host inspection, diagnosis, and target discovery.` : '', hasTool('lexora_system_action') ? 'Use lexora_system_action for supported structured host state changes.' : '', diff --git a/apps/buddy/service/src/approvals/toolClassification.ts b/apps/buddy/service/src/approvals/toolClassification.ts index 36229483d..f88946449 100644 --- a/apps/buddy/service/src/approvals/toolClassification.ts +++ b/apps/buddy/service/src/approvals/toolClassification.ts @@ -23,6 +23,7 @@ export interface BuddyToolClassification { forceAsk?: boolean requireApproval?: boolean shellBoundary?: 'sandbox' + shellDialect?: import('../../../shared/shell/shellProgram').ShellDialect paths?: readonly PermissionPath[] validateBeforeExecution?: () => Promise } diff --git a/apps/buddy/service/src/buddies/readBuddyToolOptions.ts b/apps/buddy/service/src/buddies/readBuddyToolOptions.ts index 0d1aa8842..c2830ff79 100644 --- a/apps/buddy/service/src/buddies/readBuddyToolOptions.ts +++ b/apps/buddy/service/src/buddies/readBuddyToolOptions.ts @@ -1,11 +1,13 @@ import type { BuddyToolOption } from '../../../shared/buddies/buddyApi' import type { BuddyCapabilityFactory } from '../agent/extensions/BuddyCapability' +import type { ExtensionShellRegistry } from '../sandbox/ShellSelection' import { randomUUID } from 'node:crypto' import process from 'node:process' import { toolCatalogId, toolCatalogPolicies } from '../agent/extensions/discovery/toolCatalog' import { getActivePiBuiltinToolNames } from '../agent/extensions/piBuiltinTools' +import { SHELL_COMMAND_DISCLOSURE } from '../sandbox/ShellSelection' -export async function readBuddyToolOptions(createCapabilities: BuddyCapabilityFactory, cwd: string, signal?: AbortSignal): Promise { +export async function readBuddyToolOptions(createCapabilities: BuddyCapabilityFactory, cwd: string, signal?: AbortSignal, shells?: Pick): Promise { const lifetime = new AbortController() const capabilities = await createCapabilities({ conversationId: `catalog-${randomUUID()}`, @@ -21,7 +23,9 @@ export async function readBuddyToolOptions(createCapabilities: BuddyCapabilityFa const failures: unknown[] = [] try { signal?.throwIfAborted() - const options = toolCatalogPolicies(getActivePiBuiltinToolNames(process.platform), capabilities.flatMap(capability => capability.disclosure ?? [])) + const snapshot = await shells?.snapshot(signal) + const policies = [...capabilities.flatMap(capability => capability.disclosure ?? []), ...snapshot?.enabled ? [SHELL_COMMAND_DISCLOSURE] : []] + const options = toolCatalogPolicies(getActivePiBuiltinToolNames(process.platform), policies) .flatMap(policy => policy.tools.map(tool => ({ id: toolCatalogId(policy.source, tool), name: tool.name, diff --git a/apps/buddy/service/src/permissions/PermissionEngine.ts b/apps/buddy/service/src/permissions/PermissionEngine.ts index 4ce980c77..5d5907537 100644 --- a/apps/buddy/service/src/permissions/PermissionEngine.ts +++ b/apps/buddy/service/src/permissions/PermissionEngine.ts @@ -39,6 +39,7 @@ const BUILTIN_TOOL_ACCESS: Record = { find: 'read', grep: 'read', lexora_host_shell: 'execute', + lexora_shell: 'execute', ls: 'read', powershell: 'execute', read: 'read', @@ -177,7 +178,11 @@ export class PermissionEngine { } async #classifyShell(request: PermissionRequest): Promise { - const toolName = request.toolName === 'lexora_host_shell' ? this.#hostShellToolName : request.toolName as ShellToolName + if (request.shellDialect && request.shellDialect !== 'bash' && request.shellDialect !== 'powershell') { + readStringProperty(request.arguments, 'command') + return { type: 'ask', forceAsk: true, kind: 'shell', reason: 'unsupported-syntax', summary: 'Review a host command in this Shell dialect' } + } + const toolName = request.shellDialect ?? (request.toolName === 'lexora_host_shell' ? this.#hostShellToolName : request.toolName as ShellToolName) const policy = this.#shellPolicies[toolName] if (!policy) return undefined @@ -379,7 +384,7 @@ function applyApprovalPolicy( return ask({ kind: request.approval?.kind ?? approvalKindFor(access), paths: toDecisionPaths(classifications), - ...(['bash', 'powershell', 'lexora_host_shell'].includes(request.toolName) + ...(['bash', 'powershell', 'lexora_shell', 'lexora_host_shell'].includes(request.toolName) && access === 'execute' ? { shell: { cwd: request.cwd, reason: 'manual-policy' as const, boundary: request.shellBoundary ?? 'host' as const } } : {}), diff --git a/apps/buddy/service/src/permissions/ToolAuthorizationService.ts b/apps/buddy/service/src/permissions/ToolAuthorizationService.ts index d0370d5a0..ae26b66b9 100644 --- a/apps/buddy/service/src/permissions/ToolAuthorizationService.ts +++ b/apps/buddy/service/src/permissions/ToolAuthorizationService.ts @@ -171,6 +171,7 @@ export class ToolAuthorizationService { forceAsk: declared.forceAsk, requireApproval: declared.requireApproval, shellBoundary: declared.shellBoundary, + shellDialect: declared.shellDialect, grants: options.getGrants(), sandboxDirectories: options.getSandboxDirectories?.(run), owner: options.owner, diff --git a/apps/buddy/service/src/permissions/__tests__/PermissionEngine.spec.ts b/apps/buddy/service/src/permissions/__tests__/PermissionEngine.spec.ts index bd0244390..585d30755 100644 --- a/apps/buddy/service/src/permissions/__tests__/PermissionEngine.spec.ts +++ b/apps/buddy/service/src/permissions/__tests__/PermissionEngine.spec.ts @@ -17,6 +17,29 @@ afterEach(async () => { }) describe('permissionEngine', () => { + it('uses trusted selected dialect metadata and never applies Bash safety rules to CMD or Zsh host execution', async () => { + const fixture = await createFixture() + const engine = createEngine(fixture.home) + for (const shellDialect of ['cmd', 'zsh', 'sh'] as const) { + const input = request(fixture, { + toolName: 'lexora_shell', + arguments: { command: 'echo harmless' }, + profile: 'full_access', + shellDialect, + }) + await expect(engine.decide(input)).resolves.toMatchObject({ type: 'ask', shell: { reason: 'unsupported-syntax' } }) + await expect(engine.decide({ ...input, profile: 'workspace_write', shellBoundary: 'sandbox' })).resolves.toEqual({ type: 'allow' }) + await expect(engine.decide({ ...input, approvalAvailable: false })).resolves.toMatchObject({ type: 'deny', code: 'APPROVAL_UNAVAILABLE_IN_BACKGROUND' }) + } + await expect(engine.decide(request(fixture, { + toolName: 'lexora_shell', + arguments: { command: 'Write-Output hello' }, + profile: 'full_access', + shellDialect: 'powershell', + approvalPolicy: 'manual', + }))).resolves.toMatchObject({ type: 'ask', shell: { reason: 'manual-policy', boundary: 'host' } }) + }) + it('allows verified Git queries across profiles and backgrounds, while preserving manual approval', async () => { const fixture = await createGitFixture() const engine = createEngine(fixture.home) diff --git a/apps/buddy/service/src/permissions/permissionContract.ts b/apps/buddy/service/src/permissions/permissionContract.ts index cd61b07f3..f2d7580d8 100644 --- a/apps/buddy/service/src/permissions/permissionContract.ts +++ b/apps/buddy/service/src/permissions/permissionContract.ts @@ -82,6 +82,7 @@ export interface PermissionRequest { forceAsk?: boolean requireApproval?: boolean shellBoundary?: 'sandbox' + shellDialect?: import('../../../shared/shell/shellProgram').ShellDialect grants: readonly PermissionGrant[] sandboxDirectories?: readonly SandboxDirectoryGrant[] owner: GrantOwner diff --git a/apps/buddy/service/src/sandbox/ShellSelection.ts b/apps/buddy/service/src/sandbox/ShellSelection.ts new file mode 100644 index 000000000..9c7ea5f88 --- /dev/null +++ b/apps/buddy/service/src/sandbox/ShellSelection.ts @@ -0,0 +1,82 @@ +import type { ExtensionShellDescriptor } from '../../../shared/extensions/extensionShell' +import type { RuntimeRpcPeerContract } from '../../../shared/runtime/rpcPeer' +import type { ShellCandidate, ShellProgram } from '../../../shared/shell/shellProgram' +import type { BuddyToolDisclosurePolicy } from '../agent/extensions/discovery/toolDiscoveryContract' +import { currentPlatform } from '../../../platform/currentPlatform' +import { extensionShellDescriptorSchema, extensionShellRpc } from '../../../shared/extensions/extensionShell' + +export const SHELL_COMMAND_TOOL_NAME = 'lexora_shell' +export const SYSTEM_SHELL_ID = 'system' +export const SHELL_COMMAND_DISCLOSURE: BuddyToolDisclosurePolicy = { + source: { kind: 'builtin', id: 'shell', title: 'Shell command' }, + exposure: 'direct', + keywords: 'shell command bash zsh sh powershell cmd 命令', + tools: [{ name: SHELL_COMMAND_TOOL_NAME }], +} + +export interface SelectedShell { + id: string + candidate: Readonly + program: Readonly + extensionId: string + revision: string +} + +export class ShellSelection { + readonly enabled: boolean + readonly candidates: readonly SelectedShell[] + readonly defaultId: string + readonly #readCurrent: (signal?: AbortSignal) => Promise + + constructor(descriptors: readonly ExtensionShellDescriptor[], readCurrent: (signal?: AbortSignal) => Promise) { + this.#readCurrent = readCurrent + this.enabled = descriptors.length > 0 + this.candidates = descriptors.flatMap(descriptor => descriptor.candidates.map(candidate => Object.freeze({ + id: `${descriptor.extensionId}/${candidate.id}`, + candidate: Object.freeze({ ...candidate, ...currentPlatform.hostShellSupervision ? {} : { available: false, reason: 'SHELL_SUPERVISION_UNAVAILABLE' } }), + program: Object.freeze({ dialect: candidate.dialect, executable: candidate.executable }), + extensionId: descriptor.extensionId, + revision: descriptor.revision, + }))) + const preferred = descriptors.flatMap(descriptor => descriptor.preferred.map(id => `${descriptor.extensionId}/${id}`)) + this.defaultId = preferred.find(id => this.candidates.some(entry => entry.id === id && entry.candidate.available)) ?? SYSTEM_SHELL_ID + } + + resolve(id: unknown = this.defaultId): SelectedShell | undefined { + if (id === SYSTEM_SHELL_ID) + return undefined + const selected = this.candidates.find(entry => entry.id === id) + if (!selected) + throw new Error('SHELL_CANDIDATE_NOT_ENABLED') + if (!selected.candidate.available) + throw new Error(`SHELL_CANDIDATE_UNAVAILABLE: ${selected.candidate.reason ?? selected.id}`) + return selected + } + + async assertCurrent(selected: SelectedShell | undefined, signal?: AbortSignal): Promise { + signal?.throwIfAborted() + if (!selected) + return + const descriptors = await this.#readCurrent(signal) + signal?.throwIfAborted() + if (!descriptors.some(descriptor => descriptor.extensionId === selected.extensionId && descriptor.revision === selected.revision)) + throw new Error('SHELL_CONTRIBUTION_REVOKED') + } +} + +export class ExtensionShellRegistry { + readonly #rpc: Pick + + constructor(rpc: Pick) { + this.#rpc = rpc + } + + async snapshot(signal?: AbortSignal): Promise { + return new ShellSelection(await this.#list(signal), signal => this.#list(signal)) + } + + async #list(signal?: AbortSignal): Promise { + const result = await this.#rpc.request(extensionShellRpc.list, {}, 30_000, signal) + return extensionShellDescriptorSchema.array().parse(result) + } +} diff --git a/apps/buddy/service/src/sandbox/__tests__/ShellSelection.spec.ts b/apps/buddy/service/src/sandbox/__tests__/ShellSelection.spec.ts new file mode 100644 index 000000000..4ef2416b0 --- /dev/null +++ b/apps/buddy/service/src/sandbox/__tests__/ShellSelection.spec.ts @@ -0,0 +1,115 @@ +import type { ToolDefinition } from '@earendil-works/pi-coding-agent' +import type { ExtensionShellDescriptor } from '../../../../shared/extensions/extensionShell' +import type { SandboxCommand } from '../../../../shared/permissions/shellSandbox' +import { Buffer } from 'node:buffer' +import process from 'node:process' +import { describe, expect, it } from 'vitest' +import { ToolAuthorizationService } from '../../permissions/ToolAuthorizationService' +import { SandboxDirectoryPermissions } from '../SandboxDirectoryPermissions' +import { createShellCapability } from '../shellCapability' +import { resolveShellExecution } from '../shellExecution' +import { ShellSelection } from '../ShellSelection' + +function descriptor(): ExtensionShellDescriptor { + return { + extensionId: 'tests.shells', + revision: 'package-1', + configurationRevision: 'config-1', + preferred: [], + candidates: [ + { id: 'bash', label: 'Bash', dialect: 'bash', executable: '/bin/bash', version: '5', available: true }, + { id: 'zsh', label: 'Zsh', dialect: 'zsh', executable: '/bin/zsh', version: '5', available: false, reason: 'NOT_FOUND' }, + ], + } +} + +describe('shell selection snapshots', () => { + it('preserves the platform default until preferences opt in and rejects explicit missing or arbitrary paths', () => { + const entry = descriptor() + const snapshot = new ShellSelection([entry], async () => [entry]) + expect(snapshot.enabled).toBe(true) + expect(snapshot.defaultId).toBe('system') + expect(snapshot.resolve()).toBeUndefined() + expect(() => snapshot.resolve('/bin/bash')).toThrow('SHELL_CANDIDATE_NOT_ENABLED') + expect(() => snapshot.resolve('tests.shells/zsh')).toThrow('SHELL_CANDIDATE_UNAVAILABLE') + entry.preferred = ['zsh', 'bash'] + expect(new ShellSelection([entry], async () => [entry]).defaultId).toBe('tests.shells/bash') + expect(new ShellSelection([], async () => []).enabled).toBe(false) + }) + + it('freezes configuration for the turn but revokes new starts after disable or package replacement', async () => { + const entry = descriptor() + let current = [entry] + const snapshot = new ShellSelection(current, async () => current) + const selected = snapshot.resolve('tests.shells/bash')! + entry.candidates[0]!.executable = '/other/bash' + entry.configurationRevision = 'config-2' + entry.candidates = [] + expect(selected.program.executable).toBe('/bin/bash') + await expect(snapshot.assertCurrent(selected)).resolves.toBeUndefined() + current = [{ ...entry, revision: 'package-2' }] + await expect(snapshot.assertCurrent(selected)).rejects.toThrow('SHELL_CONTRIBUTION_REVOKED') + current = [] + await expect(snapshot.assertCurrent(selected)).rejects.toThrow('SHELL_CONTRIBUTION_REVOKED') + }) + + it('keeps candidate identities distinct across providers', () => { + const first = descriptor() + const second = { ...descriptor(), extensionId: 'tests.other' } + const snapshot = new ShellSelection([first, second], async () => [first, second]) + expect(snapshot.resolve('tests.shells/bash')?.extensionId).toBe('tests.shells') + expect(snapshot.resolve('tests.other/bash')?.extensionId).toBe('tests.other') + expect(() => snapshot.resolve('bash')).toThrow('SHELL_CANDIDATE_NOT_ENABLED') + }) + + it('uses the selected program once, preserves failure and legacy meaning, and exposes interpreter-bound approval identity', async () => { + const controller = new AbortController() + const run = { runId: 'test-run', signal: controller.signal, flushProjectedEvents: async () => {}, onToolExecutionAuthorized: async () => {} } + const descriptors = [descriptor()] + const shells = new ShellSelection(descriptors, async () => descriptors) + const submitted: Pick[] = [] + const authorization = new ToolAuthorizationService({ + approvalAvailable: true, + approvalPolicy: 'policy', + executionProfile: 'workspace_write', + cwd: process.cwd(), + owner: { kind: 'conversation', id: 'test-task' }, + getGrants: () => [], + approvalService: { request: async () => ({ approvalId: 'test-approval', decision: 'denied' }) }, + }) + const capability = createShellCapability({ + shells, + cwd: process.cwd(), + execution: resolveShellExecution('workspace_write', 'linux'), + getGrants: () => [], + getRunContext: () => run, + authorization, + directoryPermissions: new SandboxDirectoryPermissions(), + sandbox: { exec: async (input, options) => { + submitted.push(input) + options.onData(Buffer.from('output')) + return { exitCode: 7 } + } }, + }) + const tools: ToolDefinition[] = [] + await capability.extension.factory({ registerTool: (tool: ToolDefinition) => tools.push(tool) } as never) + const tool = tools.find(tool => tool.name === 'lexora_shell')! + const parameters = { command: 'printf output; exit 7', shell: 'tests.shells/bash' } + const result = await tool.execute('call-1', parameters, controller.signal, undefined, {} as never) + expect(result).toMatchObject({ isError: true, structuredContent: { exit_code: 7, shell: { id: parameters.shell, dialect: 'bash', executable: '/bin/bash' } } }) + expect(submitted).toHaveLength(1) + expect(submitted[0]).toMatchObject({ command: parameters.command, shell: { dialect: 'bash', executable: '/bin/bash' } }) + const event = { type: 'tool_call', toolCallId: 'call-1', toolName: tool.name, input: parameters } as const + const selected = await capability.classify(event, controller.signal) + const system = await capability.classify({ ...event, input: { command: parameters.command, shell: 'system' } }, controller.signal) + expect(selected).toMatchObject({ shellDialect: 'bash', shellBoundary: 'sandbox' }) + expect(selected && 'approval' in selected && selected.approval?.reuse).not.toEqual(system && 'approval' in system && system.approval?.reuse) + await tools.find(tool => tool.name === 'bash')!.execute('legacy', parameters, controller.signal, undefined, {} as never) + expect(submitted[1]?.shell).toBeUndefined() + await expect(tool.execute('invalid', { ...parameters, shell: '/tmp/unknown' }, controller.signal, undefined, {} as never)).rejects.toThrow('SHELL_CANDIDATE_NOT_ENABLED') + expect(submitted).toHaveLength(2) + descriptors.length = 0 + await expect(tool.execute('revoked', parameters, controller.signal, undefined, {} as never)).rejects.toThrow('SHELL_CONTRIBUTION_REVOKED') + expect(submitted).toHaveLength(2) + }) +}) diff --git a/apps/buddy/service/src/sandbox/backends/srt/__tests__/shellPrograms.spec.ts b/apps/buddy/service/src/sandbox/backends/srt/__tests__/shellPrograms.spec.ts new file mode 100644 index 000000000..175dd55d9 --- /dev/null +++ b/apps/buddy/service/src/sandbox/backends/srt/__tests__/shellPrograms.spec.ts @@ -0,0 +1,74 @@ +import type { SandboxProcessInput } from '../../../../../../shared/permissions/shellSandbox' +import { Buffer } from 'node:buffer' +import { randomUUID } from 'node:crypto' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { homedir, tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import process from 'node:process' +import { describe, expect, it, vi } from 'vitest' +import { createSandboxDirectory } from '../../../../../../platform/process/sandboxDirectory' +import { createSandboxEnvironment } from '../../../../../../platform/process/sandboxEnvironment' +import { detectShellPrograms } from '../../../../../../platform/process/shellPrograms' +import { runSandboxCommand } from '../../../runSandboxCommand' + +describe.skipIf(process.platform !== 'linux')('explicit shells through SRT', () => { + it('retains quoting, multiline, exit status and denied writes for installed POSIX shells', async () => { + const environment = { ...process.env } + const buddyRoot = process.cwd() + const candidates = (await detectShellPrograms()).filter(candidate => candidate.available && ['bash', 'sh', 'zsh'].includes(candidate.dialect)) + expect(candidates.some(candidate => candidate.id === 'bash')).toBe(true) + const root = await mkdtemp(join(tmpdir(), 'lexora-shell-program-')) + const workspace = join(root, 'workspace') + const outside = join(root, 'outside') + await mkdir(workspace) + await writeFile(outside, 'preserved') + try { + for (const candidate of candidates) { + const privateRoot = await createSandboxDirectory() + try { + const filtered = createSandboxEnvironment(environment, privateRoot) + filtered.LEXORA_BUDDY_PROCESS_CONTROL = environment.LEXORA_BUDDY_PROCESS_CONTROL! + for (const key of new Set([...Object.keys(process.env), ...Object.keys(filtered)])) + vi.stubEnv(key, filtered[key]) + process.chdir(privateRoot) + const input: SandboxProcessInput = { + command: `printf '%s' '你好 "quoted"' > result\ncat result\nprintf denied > '${outside}'\nexit 7`, + shell: { dialect: candidate.dialect, executable: candidate.executable }, + cwd: workspace, + home: homedir(), + privateRoot, + readOnly: false, + roots: [workspace], + workspaceRoots: [workspace], + resourceReadRoots: [], + additionalDirectories: [], + protectedRoots: [], + sensitiveRoots: [], + requestId: randomUUID(), + timeout: 5, + path: environment.PATH ?? '', + searchDirectory: resolve(buddyRoot, `.output/build/search-tools/linux-${process.arch}`), + backend: { kind: 'linux-srt', sandboxDirectory: resolve(buddyRoot, `.output/build/shell-sandbox/linux-${process.arch}`) }, + } + const output: Buffer[] = [] + const result = await runSandboxCommand(input, { + signal: new AbortController().signal, + approveNetwork: async () => false, + onData: data => output.push(data), + }) + expect(result, Buffer.concat(output).toString()).toEqual({ ok: true, exitCode: 7 }) + expect(await readFile(join(workspace, 'result'), 'utf8')).toBe('你好 "quoted"') + expect(await readFile(outside, 'utf8')).toBe('preserved') + } + finally { + process.chdir(buddyRoot) + vi.unstubAllEnvs() + await rm(privateRoot, { recursive: true, force: true }) + } + } + } + finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) diff --git a/apps/buddy/service/src/sandbox/backends/srt/runSrtSandbox.ts b/apps/buddy/service/src/sandbox/backends/srt/runSrtSandbox.ts index 3f1269dcc..6c388b139 100644 --- a/apps/buddy/service/src/sandbox/backends/srt/runSrtSandbox.ts +++ b/apps/buddy/service/src/sandbox/backends/srt/runSrtSandbox.ts @@ -6,6 +6,7 @@ import { spawn } from 'node:child_process' import process from 'node:process' import { SandboxManager } from '@anthropic-ai/sandbox-runtime' import { SANDBOX_LOOPBACK_NO_PROXY } from '../../../../../platform/process/sandboxEnvironment' +import { buildShellInvocation } from '../../../../../platform/process/shellPrograms' import { runSupervisedShellProcess } from '../../../../../platform/process/supervisedShellProcess' import { createSandboxPolicy } from './createPolicy' @@ -39,8 +40,9 @@ export async function runSrtSandbox(input: SrtSandboxInput, options: SandboxExec const { config, path } = await createSandboxPolicy(input, options.signal) const noProxy = process.platform === 'linux' ? SANDBOX_LOOPBACK_NO_PROXY : '' await SandboxManager.initialize(config, ({ host, port }) => options.approveNetwork({ host, port: port ?? 443 }), false) + const invocation = buildShellInvocation(input.shell ?? { dialect: 'bash', executable: '/bin/bash' }, input.command) const launch = await SandboxManager.wrapWithSandboxArgv( - `export TMPDIR=${quote(process.env.TMPDIR!)} NO_PROXY=${quote(noProxy)} no_proxy=${quote(noProxy)}; exec /bin/bash --noprofile --norc -c ${quote(input.command)}`, + `export TMPDIR=${quote(process.env.TMPDIR!)} NO_PROXY=${quote(noProxy)} no_proxy=${quote(noProxy)}; exec ${[invocation.shell, ...invocation.args, invocation.command].map(quote).join(' ')}`, '/bin/bash', undefined, options.signal, diff --git a/apps/buddy/service/src/sandbox/backends/windows/createPolicy.ts b/apps/buddy/service/src/sandbox/backends/windows/createPolicy.ts index 815640ee8..bc91dc9a9 100644 --- a/apps/buddy/service/src/sandbox/backends/windows/createPolicy.ts +++ b/apps/buddy/service/src/sandbox/backends/windows/createPolicy.ts @@ -2,14 +2,19 @@ import type { SandboxBackendInput } from '../../../../../shared/permissions/shel import type { SHELL_SANDBOX_BACKEND } from '../../../../../shared/platform/identifiers' import type { SandboxFilesystemPolicyInput } from '../../sandboxFilesystemPolicy' import { dirname, join } from 'node:path' +import { windowsShellRuntimeDirectories } from '../../../../../platform/process/shellPrograms' import { validateWindowsFilePath } from '../../../../../platform/windows/filePath' import { createSandboxFilesystemPolicy, existingSandboxPaths, inspectSandboxPath } from '../../sandboxFilesystemPolicy' -type WindowsSandboxPolicyInput = SandboxFilesystemPolicyInput & Pick, 'backend' | 'path' | 'searchDirectory'> +type WindowsSandboxPolicyInput = SandboxFilesystemPolicyInput & Pick, 'backend' | 'path' | 'searchDirectory' | 'shell'> export async function createWindowsSandboxPolicy(input: WindowsSandboxPolicyInput, signal: AbortSignal) { const grants = await createSandboxFilesystemPolicy(input, signal) - const roots = new Set(await existingSandboxPaths([dirname(input.backend.shell), join(input.backend.systemRoot, 'System32'), input.searchDirectory])) + const shell = input.shell?.executable ?? input.backend.shell + const shellRoots = [dirname(shell)] + if (input.shell && ['bash', 'sh', 'zsh'].includes(input.shell.dialect)) + shellRoots.push(...windowsShellRuntimeDirectories(shell)) + const roots = new Set(await existingSandboxPaths([...shellRoots, join(input.backend.systemRoot, 'System32'), input.searchDirectory])) for (const root of roots) grants.push(await inspectSandboxPath(root, 'read')) diff --git a/apps/buddy/service/src/sandbox/backends/windows/runWindowsSandbox.ts b/apps/buddy/service/src/sandbox/backends/windows/runWindowsSandbox.ts index c387aa8a3..30d54be41 100644 --- a/apps/buddy/service/src/sandbox/backends/windows/runWindowsSandbox.ts +++ b/apps/buddy/service/src/sandbox/backends/windows/runWindowsSandbox.ts @@ -8,11 +8,14 @@ import { createHttpProxyServer } from '@anthropic-ai/sandbox-runtime/dist/sandbo import { resolveParentProxy } from '@anthropic-ai/sandbox-runtime/dist/sandbox/parent-proxy.js' import { createResolvedAddressGuard } from '@anthropic-ai/sandbox-runtime/dist/sandbox/resolved-address-guard.js' import { createWindowsSandboxEnvironment } from '../../../../../platform/process/sandboxEnvironment' +import { buildShellInvocation } from '../../../../../platform/process/shellPrograms' import { runWindowsSandboxProcess } from '../../../../../platform/process/windowsSandboxProcess' import { ShellSandboxError } from '../../../../../shared/permissions/shellSandbox' import { createWindowsSandboxPolicy } from './createPolicy' export async function runWindowsSandbox(input: SandboxBackendInput, options: SandboxExecutionOptions): Promise { + const program = input.shell ?? { dialect: 'powershell' as const, executable: input.backend.shell } + buildShellInvocation(program, input.command) const { grants, path } = await createWindowsSandboxPolicy(input, options.signal) const token = randomBytes(32).toString('hex') const guard = createResolvedAddressGuard({ deniedResolvedAddresses: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '100.64.0.0/10', 'fc00::/7'] }) @@ -39,13 +42,16 @@ export async function runWindowsSandbox(input: SandboxBackendInput ShellExecution getGrants: () => readonly DirectoryGrant[] resourceReadRoots?: readonly string[] @@ -45,7 +49,13 @@ export function createShellCapability(options: { ? 'Commands can use private loopback servers. Host services and other network destinations require proxy approval.' : 'Network destinations require authorization.' const shellName = options.execution.dialect - const createShell = shellName === 'powershell' ? createPowerShellToolDefinition : createBashToolDefinition + const shells = options.shells?.enabled ? options.shells : undefined + const select = (input: Record) => shells?.resolve(input.shell) + const commandParameters = Type.Object({ + command: Type.String({ minLength: 1, description: 'Noninteractive command written for the selected Shell dialect.' }), + shell: Type.Optional(Type.Union([Type.Literal(SYSTEM_SHELL_ID), ...shells?.candidates.filter(entry => entry.candidate.available).map(entry => Type.Literal(entry.id)) ?? []], { description: 'Stable detected candidate ID. Omit to use the displayed default; never supply an executable path.' })), + timeout: Type.Optional(Type.Number({ exclusiveMinimum: 0, description: 'Timeout in seconds.' })), + }, { additionalProperties: false }) const directoryParameters = Type.Object({ path: Type.String({ minLength: 1, maxLength: 4_096 }), access: Type.Union([Type.Literal('read'), Type.Literal('write')]), @@ -53,18 +63,47 @@ export function createShellCapability(options: { }, { additionalProperties: false }) return { disclosure: [ + ...shells ? [SHELL_COMMAND_DISCLOSURE] : [], { source: { kind: 'builtin', id: 'sandbox', title: 'Directory authorization' }, exposure: 'direct', keywords: '授权 目录 authorize directory', tools: [{ name: 'lexora_authorize_directory' }] }, { source: { kind: 'builtin', id: 'host', title: 'Host shell' }, exposure: 'on_demand', keywords: '宿主 命令 桌面 凭据 host shell command desktop credentials', tools: [HOST_TOOLS.shell] }, ], workspaceMutationTools: ['lexora_host_shell'], mutationEffect(event, classification) { - if (event.toolName !== shellName && event.toolName !== 'lexora_host_shell') + if (event.toolName !== shellName && event.toolName !== SHELL_COMMAND_TOOL_NAME && event.toolName !== 'lexora_host_shell') return null if (!currentPlatform.hostShellSupervision) return { kind: 'host', ownedProcess: false } return { kind: event.toolName === 'lexora_host_shell' || classification.shellBoundary !== 'sandbox' ? 'host' : 'workspace', ownedProcess: true } }, - async classify(event) { + async classify(event, signal) { + if (shells && (event.toolName === SHELL_COMMAND_TOOL_NAME || event.toolName === 'lexora_host_shell')) { + let selected: SelectedShell | undefined + try { + selected = select(event.input) + await shells.assertCurrent(selected, signal) + } + catch (error) { + return { blocked: true, reason: error instanceof Error ? error.message : 'SHELL_CANDIDATE_UNAVAILABLE' } + } + const boundary = event.toolName === 'lexora_host_shell' ? 'host' : (options.getExecution?.() ?? options.execution).boundary + const identity = [event.toolName, selected?.id ?? SYSTEM_SHELL_ID, selected?.program ?? shellName, selected?.revision ?? null, boundary, options.cwd] + return { + access: 'execute', + shellDialect: selected?.program.dialect ?? shellName, + shellBoundary: boundary === 'sandbox' ? 'sandbox' : undefined, + approval: { + summary: `Run ${selected ? `${selected.candidate.label} (${selected.program.dialect}, ${selected.candidate.version})` : `the platform default ${shellName}`} command on ${boundary}`, + reuse: { operation: [...identity, event.input], source: identity }, + }, + validateBeforeExecution: async () => { + try { + await shells.assertCurrent(selected, signal) + return null + } + catch (error) { return { blocked: true, reason: error instanceof Error ? error.message : 'SHELL_CONTRIBUTION_REVOKED' } } + }, + } + } if (event.toolName === shellName) return { access: 'execute', get shellBoundary() { return (options.getExecution?.() ?? options.execution).boundary === 'sandbox' ? 'sandbox' as const : undefined } } if (event.toolName === 'lexora_host_shell') @@ -76,7 +115,7 @@ export function createShellCapability(options: { extension: { name: SHELL_SANDBOX_EXTENSION, factory(pi) { - const native = createShell(options.cwd, { + const createNative = (selected?: SelectedShell) => (selected?.program.dialect === 'powershell' || (!selected && shellName === 'powershell') ? createPowerShellToolDefinition : createBashToolDefinition)(options.cwd, { exposeSessionEnvironment: false, ...(currentPlatform.hostShellSupervision ? { @@ -85,8 +124,9 @@ export function createShellCapability(options: { const invocation = options.mutations?.getCurrentInvocation() if (options.mutations && !invocation) throw new Error('SHELL_INVOCATION_REQUIRED') - const shell = shellName === 'powershell' ? getPowerShellConfig() : getShellConfig() - return runSupervisedShellProcess({ ...shell, command, cwd, environment: execOptions.env ?? process.env }, { + const shell = selected ? buildShellInvocation(selected.program, command) : { ...(shellName === 'powershell' ? getPowerShellConfig() : getShellConfig()), command } + const environment = selected ? createShellProgramEnvironment(selected.program, execOptions.env ?? process.env) : execOptions.env ?? process.env + return runSupervisedShellProcess({ ...shell, cwd, environment }, { ...execOptions, receiptDirectory: options.receiptDirectory, onSettlement(settlement) { @@ -99,103 +139,137 @@ export function createShellCapability(options: { } : {}), }) - pi.registerTool({ - ...native, - description: `${native.description}\nThe current permission mode determines the execution boundary. Read-only and workspace modes use an OS sandbox; full access runs on the host. Ordinary files are readable without directory approval. Sandbox policy permits writes only in authorized directories and denies reads of known credential and application-private paths. ${currentPlatform.id === OPERATING_SYSTEM.Windows ? `${WINDOWS_SANDBOX_FILESYSTEM_LIMITS} ` : ''}${networkBoundary} No automatic host fallback.`, - promptGuidelines: [ - `Use ${shellName} for ordinary commands, pipelines, scripts and project builds under the current permission mode. Ordinary reads need no approval. If the OS denies an ordinary directory, lexora_authorize_directory can prepare read access without a prompt. Do not retry protected credential paths through another interpreter or execution boundary.`, - 'In isolated modes, user HOME, configuration paths and supported toolchain variables are preserved; only temporary files use private TMPDIR/TEMP directories. Shells do not load user profiles. Use installed shims or explicit toolchain execution (for example, mise exec) rather than assuming interactive activation. Tool installation, cache and state writes outside authorized directories still require directory authorization. Host credential variables and desktop IPC variables are not inherited. Do not access protected credential paths or write repository Git metadata. Use lexora_authorize_directory with the least access needed for additional directories; additional write authorization expires with the current run. Windows may reuse read-access preparation and ACL material across runs; this does not authorize writes or other tools. Use lexora_host_shell only when the requested task genuinely needs host access; if its definition is missing, search that exact name with lexora_tool_search. Never use it to retry a request the user denied.', - ], - async execute(toolCallId, parameters, signal, onUpdate, context) { - const run = options.getRunContext() - const execution = options.getExecution?.() ?? options.execution - if (!run) - throw new ShellSandboxError('SANDBOX_UNAVAILABLE') - await options.assertExecutionAllowed?.({ run, boundary: execution.boundary, readOnly: execution.readOnly }) - if (execution.boundary === 'host') - return native.execute(toolCallId, parameters, AbortSignal.any([run.signal, ...signal ? [signal] : []]), onUpdate, context) - if (!options.sandbox) - throw new ShellSandboxError('SANDBOX_UNAVAILABLE') - const lifetime = new AbortController() - const executionSignal = AbortSignal.any([lifetime.signal, run.signal, ...signal ? [signal] : []]) - const tool = createShell(options.cwd, { - exposeSessionEnvironment: false, - operations: { - exec: async (command, cwd, execOptions) => { - const failures: (SandboxNetworkTarget & { code: string })[] = [] - let omitted = 0 - const recordFailure = (target: SandboxNetworkTarget, code: string) => { - if (failures.length < 16) - failures.push({ ...target, code }) - else - omitted++ - } - try { - return await options.sandbox!.exec({ - command, - cwd, - readOnly: execution.readOnly, - roots: [...new Set(options.getGrants().map(grant => grant.canonicalRoot))], - workspaceRoots: options.getGrants().filter(grant => grant.kind === 'workspace').map(grant => grant.canonicalRoot), - additionalDirectories: [...options.directoryPermissions.get(run)], - resourceReadRoots: [...(options.resourceReadRoots ?? [])], - timeout: execOptions.timeout, - }, { ...execOptions, signal: executionSignal }, async (target) => { - if (executionSignal.aborted) - return false - try { - const reason = await options.authorization.authorize({ - input: parameters, - toolCallId, - toolName: shellName, - }, run, { access: 'network' }, { network: target, signal: executionSignal }) - if (reason !== null) - recordFailure(target, reason) - return reason === null - } - catch (error) { - recordFailure(target, error instanceof ApprovalCancelledError || error instanceof ApprovalExpiredError ? error.code : 'NETWORK_APPROVAL_FAILED') - throw error - } - }, { - onSettlement: (() => { - const invocation = options.mutations?.getCurrentInvocation() - if (options.mutations && !invocation) - throw new Error('SHELL_INVOCATION_REQUIRED') - return (settlement: OwnedProcessSettlement) => { - if (invocation && !options.mutations!.recordOwnedProcessSettlement({ ...settlement, invocationId: invocation.invocationId })) - throw new Error('SHELL_INVOCATION_EXPIRED') + const controlled = (selected?: SelectedShell) => { + const native = createNative(selected) + const createShell = selected?.program.dialect === 'powershell' || (!selected && shellName === 'powershell') ? createPowerShellToolDefinition : createBashToolDefinition + return { + ...native, + description: `${native.description}\nThe current permission mode determines the execution boundary. Read-only and workspace modes use an OS sandbox; full access runs on the host. Ordinary files are readable without directory approval. Sandbox policy permits writes only in authorized directories and denies reads of known credential and application-private paths. ${currentPlatform.id === OPERATING_SYSTEM.Windows ? `${WINDOWS_SANDBOX_FILESYSTEM_LIMITS} ` : ''}${networkBoundary} No automatic host fallback.`, + promptGuidelines: [ + `Use ${shellName} for ordinary commands, pipelines, scripts and project builds under the current permission mode. Ordinary reads need no approval. If the OS denies an ordinary directory, lexora_authorize_directory can prepare read access without a prompt. Do not retry protected credential paths through another interpreter or execution boundary.`, + 'In isolated modes, user HOME, configuration paths and supported toolchain variables are preserved; only temporary files use private TMPDIR/TEMP directories. Shells do not load user profiles. Use installed shims or explicit toolchain execution (for example, mise exec) rather than assuming interactive activation. Tool installation, cache and state writes outside authorized directories still require directory authorization. Host credential variables and desktop IPC variables are not inherited. Do not access protected credential paths or write repository Git metadata. Use lexora_authorize_directory with the least access needed for additional directories; additional write authorization expires with the current run. Windows may reuse read-access preparation and ACL material across runs; this does not authorize writes or other tools. Use lexora_host_shell only when the requested task genuinely needs host access; if its definition is missing, search that exact name with lexora_tool_search. Never use it to retry a request the user denied.', + ], + execute: (async (toolCallId, parameters, signal, onUpdate, context) => { + const run = options.getRunContext() + const execution = options.getExecution?.() ?? options.execution + if (!run) + throw new ShellSandboxError('SANDBOX_UNAVAILABLE') + await shells?.assertCurrent(selected, AbortSignal.any([run.signal, ...signal ? [signal] : []])) + await options.assertExecutionAllowed?.({ run, boundary: execution.boundary, readOnly: execution.readOnly }) + if (execution.boundary === 'host') + return native.execute(toolCallId, parameters, AbortSignal.any([run.signal, ...signal ? [signal] : []]), onUpdate, context) + if (!options.sandbox) + throw new ShellSandboxError('SANDBOX_UNAVAILABLE') + const lifetime = new AbortController() + const executionSignal = AbortSignal.any([lifetime.signal, run.signal, ...signal ? [signal] : []]) + const tool = createShell(options.cwd, { + exposeSessionEnvironment: false, + operations: { + exec: async (command, cwd, execOptions) => { + const failures: (SandboxNetworkTarget & { code: string })[] = [] + let omitted = 0 + const recordFailure = (target: SandboxNetworkTarget, code: string) => { + if (failures.length < 16) + failures.push({ ...target, code }) + else + omitted++ + } + try { + return await options.sandbox!.exec({ + command, + cwd, + ...(selected ? { shell: selected.program } : {}), + readOnly: execution.readOnly, + roots: [...new Set(options.getGrants().map(grant => grant.canonicalRoot))], + workspaceRoots: options.getGrants().filter(grant => grant.kind === 'workspace').map(grant => grant.canonicalRoot), + additionalDirectories: [...options.directoryPermissions.get(run)], + resourceReadRoots: [...(options.resourceReadRoots ?? [])], + timeout: execOptions.timeout, + }, { ...execOptions, signal: executionSignal }, async (target) => { + if (executionSignal.aborted) + return false + try { + const reason = await options.authorization.authorize({ + input: parameters, + toolCallId, + toolName: selected ? SHELL_COMMAND_TOOL_NAME : shellName, + }, run, { access: 'network' }, { network: target, signal: executionSignal }) + if (reason !== null) + recordFailure(target, reason) + return reason === null } - })(), - }) - } - finally { - if (failures.length && !executionSignal.aborted) { - execOptions.onData(Buffer.from(`\n[Network authorization]\n${JSON.stringify({ requests: failures, omitted })}\nThese requests were not authorized. Do not retry a declined or cancelled request through another command or tool. An authorization error does not mean the user refused. These facts do not diagnose other command failures or override its exit status.\n`)) + catch (error) { + recordFailure(target, error instanceof ApprovalCancelledError || error instanceof ApprovalExpiredError ? error.code : 'NETWORK_APPROVAL_FAILED') + throw error + } + }, { + onSettlement: (() => { + const invocation = options.mutations?.getCurrentInvocation() + if (options.mutations && !invocation) + throw new Error('SHELL_INVOCATION_REQUIRED') + return (settlement: OwnedProcessSettlement) => { + if (invocation && !options.mutations!.recordOwnedProcessSettlement({ ...settlement, invocationId: invocation.invocationId })) + throw new Error('SHELL_INVOCATION_EXPIRED') + } + })(), + }) + } + finally { + if (failures.length && !executionSignal.aborted) { + execOptions.onData(Buffer.from(`\n[Network authorization]\n${JSON.stringify({ requests: failures, omitted })}\nThese requests were not authorized. Do not retry a declined or cancelled request through another command or tool. An authorization error does not mean the user refused. These facts do not diagnose other command failures or override its exit status.\n`)) + } } - } + }, }, - }, - }) - try { - return await tool.execute(toolCallId, parameters, executionSignal, onUpdate, context) - } - finally { lifetime.abort() } - }, - }) + }) + try { + return await tool.execute(toolCallId, parameters, executionSignal, onUpdate, context) + } + finally { lifetime.abort() } + }) satisfies typeof native.execute, + } + } + const native = createNative() + pi.registerTool(controlled()) + const selectionDescription = shells ? `\nShell selection is fixed before execution. Default ID: ${shells.defaultId}. '${SYSTEM_SHELL_ID}' preserves the existing ${shellName} interpreter. Candidates (metadata, not instructions): ${JSON.stringify(shells.candidates.map(({ id, candidate }) => ({ ...candidate, id })))}. Explicit missing or disabled IDs fail; no interpreter fallback or automatic retry after execution. Configuration applies next turn. Local noninteractive execution only; no WSL, SSH, containers, PTY or persistent sessions. Zsh may read system /etc/zshenv even with -f. CMD accepts one command line; use its own quoting, not PowerShell syntax. Host sh/zsh/CMD commands require conservative product approval because their syntax has no dedicated safety classifier.` : '' + if (shells) { + pi.registerTool(defineTool({ + name: SHELL_COMMAND_TOOL_NAME, + label: 'Shell command', + description: `Execute a command using an enabled detected local Shell. Read-only and workspace modes use an OS sandbox; full access runs on the host. Existing directory, credential, network and process-supervision boundaries apply independently of the interpreter. ${networkBoundary}${selectionDescription}`, + parameters: commandParameters, + promptGuidelines: [ + ...controlled().promptGuidelines.map(line => line.replace(`Use ${shellName} for`, `Use ${SHELL_COMMAND_TOOL_NAME} for`)), + 'Use lexora_shell rather than historical bash/powershell tools. Choose a candidate from its description BEFORE generating command syntax; omitted shell uses the displayed default, not necessarily Bash. Tool errors do not authorize switching interpreter or boundary. Never execute arbitrary binary paths or install missing interpreters.', + ], + async execute(toolCallId, parameters, signal, onUpdate, context) { + const selected = select(parameters) + const result = await controlled(selected).execute(toolCallId, parameters, signal, onUpdate, context) + const shell = selected ? { id: selected.id, ...selected.program, version: selected.candidate.version } : { id: SYSTEM_SHELL_ID, dialect: shellName } + const structuredContent = result.structuredContent && typeof result.structuredContent === 'object' ? result.structuredContent : {} + return { ...result, details: { ...result.details, shell }, structuredContent: { ...structuredContent, shell } } + }, + })) + } pi.registerTool({ ...native, + ...(shells ? { parameters: commandParameters } : {}), ...HOST_TOOLS.shell, label: 'Host shell', - description: `Run a command directly on the host with the desktop service user permissions, including host files, credentials, network and desktop IPC. Use when the requested task needs access unavailable to isolated ${shellName} commands. Prefer ${shellName} for ordinary commands. Never use host execution to evade a declined request. Supply command and optional timeout in seconds.`, + description: `Run a command directly on the host with the desktop service user permissions, including host files, credentials, network and desktop IPC. Use when the requested task needs access unavailable to isolated ${shellName} commands. Prefer ${shellName} for ordinary commands. Never use host execution to evade a declined request. Supply command and optional timeout in seconds.${selectionDescription}`, promptSnippet: 'Run an authorized host command outside the sandbox', promptGuidelines: [], async execute(toolCallId, parameters, signal, onUpdate, context) { const run = options.getRunContext() if (!run) throw new ShellSandboxError('SANDBOX_UNAVAILABLE') + const selected = select(parameters) + const executionSignal = AbortSignal.any([run.signal, ...signal ? [signal] : []]) + await shells?.assertCurrent(selected, executionSignal) + if (selected && !currentPlatform.hostShellSupervision) + throw new Error('SHELL_SUPERVISION_UNAVAILABLE') await options.assertExecutionAllowed?.({ run, boundary: 'host', readOnly: false }) - return native.execute(toolCallId, parameters, AbortSignal.any([run.signal, ...signal ? [signal] : []]), onUpdate, context) + return createNative(selected).execute(toolCallId, parameters, executionSignal, onUpdate, context) }, }) pi.registerTool(defineTool({ diff --git a/apps/buddy/shared/extensions/__tests__/extensionCompatibility.spec.ts b/apps/buddy/shared/extensions/__tests__/extensionCompatibility.spec.ts index 49c961c3c..7f0117a1c 100644 --- a/apps/buddy/shared/extensions/__tests__/extensionCompatibility.spec.ts +++ b/apps/buddy/shared/extensions/__tests__/extensionCompatibility.spec.ts @@ -6,7 +6,8 @@ it.each([ ['>=0.11.0 <1.0.0', 3, 1, 'app-too-old', true], ['<0.10.0', 3, 1, 'app-too-new', false], ['<0.10.0 || >=0.11.0', 3, 1, 'app-version-unsupported', false], - ['*', 4, 1, 'api-unsupported', true], + ['*', 5, 1, 'api-unsupported', true], + ['*', 4, 1, null, false], ['*', 3, 2, 'format-unsupported', true], ['*', 3, 1, null, false], ] as const)('classifies range %s, API %i and format %i without suggesting an upgrade for other causes', (range, apiVersion, schemaVersion, reason, upgrade) => { diff --git a/apps/buddy/shared/extensions/extensionApi.ts b/apps/buddy/shared/extensions/extensionApi.ts index 02ff31abb..53d27fe8d 100644 --- a/apps/buddy/shared/extensions/extensionApi.ts +++ b/apps/buddy/shared/extensions/extensionApi.ts @@ -1,3 +1,4 @@ +import type { ShellCandidate, ShellCandidateId } from '../shell/shellProgram' import type { WorkbenchHitRegion, WorkbenchPaneSnapshot } from '../workbench/workbenchInteraction' import type { JsonValue } from '../workbench/workbenchState' import type { ControlProposal, WorkbenchPresentation } from '../workbench/workbenchUi' @@ -11,6 +12,7 @@ import type { ExtensionManifest } from './extensionManifest' import type { ExtensionConfiguration, ExtensionConfigurationSnapshot } from './extensionSettings' import type { ExtensionWindowStyle } from './extensionWindowStyles' import { z } from 'zod' +import { shellCandidateIdSchema } from '../shell/shellProgram' import { spaceFileTargetSchema } from '../spaces/spaceFileApi' import { themeTransportSchema } from '../theme/themeApi.ts' import { workbenchPanesSchema } from '../workbench/workbenchInteraction' @@ -116,6 +118,8 @@ export type ExtensionWorkbenchEvent export const extensionManagementSchema = z.discriminatedUnion('action', [ z.object({ action: z.literal('list') }).strict(), z.object({ action: z.literal('unavailable') }).strict(), + z.object({ action: z.literal('detectShells'), id: extensionIdSchema }).strict(), + z.object({ action: z.literal('selectShellExecutable'), id: extensionIdSchema, candidate: shellCandidateIdSchema }).strict(), z.object({ action: z.literal('configuration'), id: extensionIdSchema }).strict(), z.object({ action: z.literal('configurationSnapshot'), id: extensionIdSchema }).strict(), z.object({ action: z.literal('settingConditions'), id: extensionIdSchema, items: z.array(z.string().max(180)).max(64), form: extensionConfigurationSchema.optional() }).strict(), @@ -146,6 +150,8 @@ export const extensionManagementSchema = z.discriminatedUnion('action', [ ]) export type ExtensionManagementRequest = z.infer export interface ExtensionApi { + detectShells: (id: string) => Promise + selectShellExecutable: (id: string, candidate: ShellCandidateId) => Promise settingConditions: (id: string, items: string[], form?: ExtensionConfiguration) => Promise> onConditionsChanged: (listener: (event: ExtensionConditionsChanged) => void) => () => void taskActions: () => Promise diff --git a/apps/buddy/shared/extensions/extensionManifest.ts b/apps/buddy/shared/extensions/extensionManifest.ts index 3bf42e85c..e1ec5844c 100644 --- a/apps/buddy/shared/extensions/extensionManifest.ts +++ b/apps/buddy/shared/extensions/extensionManifest.ts @@ -10,8 +10,9 @@ import { extensionConditionDefinitionSchema } from './extensionConditions.ts' import { extensionAuthorSchema } from './extensionIdentity.ts' import { extensionPathSchema } from './extensionPath.ts' import { extensionSettingsGroups, extensionSettingsModules, extensionSettingsSchema, validateExtensionSetting } from './extensionSettings.ts' +import { extensionShellContributionSchema } from './extensionShell.ts' -export const EXTENSION_API_VERSION = 3 +export const EXTENSION_API_VERSION = 4 export const EXTENSION_PROTOCOL = 'lexora-extension' export const EXTENSION_ICON_LIMIT = 64 * 1024 export const extensionIconUrlSchema = z.string().max(Math.ceil(EXTENSION_ICON_LIMIT / 3) * 4 + 40).regex(/^data:image\/(?:svg\+xml|png|jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}$/) @@ -57,6 +58,7 @@ export const extensionPlacementSchema = z.discriminatedUnion('kind', [ }) export type ExtensionPlacement = z.infer export const extensionPermissionsSchema = z.object({ + shells: z.boolean().default(false), agent: z.boolean().default(false), models: z.boolean().default(false), tasks: z.enum(['none', 'read', 'title']).default('none'), @@ -93,7 +95,7 @@ export type ExtensionMetadata = z.infer export const extensionManifestSchema = extensionMetadataSchema.extend({ schemaVersion: z.literal(1), format: z.enum(['compiled', 'source']).default('compiled'), - apiVersion: z.union([z.literal(1), z.literal(2), z.literal(EXTENSION_API_VERSION)]), + apiVersion: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(EXTENSION_API_VERSION)]), entry: extensionPathSchema.optional(), styles: z.object({ uno: z.literal(true), @@ -107,6 +109,7 @@ export const extensionManifestSchema = extensionMetadataSchema.extend({ themes: z.array(themeContributionSchema).max(64).default([]), conditions: z.array(extensionConditionDefinitionSchema).max(32).default([]), agent: extensionAgentSchema.optional(), + shells: extensionShellContributionSchema.optional(), settings: extensionSettingsSchema.prefault({}), commands: z.array(z.object({ id: contributionId, title: z.string().min(1).max(100), hidden: z.boolean().default(false), slash: workbenchSlashSchema.optional(), when: workbenchConditionSchema.optional() }).strict()).max(64).default([]), menus: z.array(z.object({ id: contributionId, command: contributionId, target: workbenchMenuSchema, order: z.number().int().min(-1000).max(1000).default(0), when: workbenchConditionSchema.optional() }).strict()).max(32).default([]), @@ -128,6 +131,24 @@ export const extensionManifestSchema = extensionMetadataSchema.extend({ issue('Theme contributions and management require API 3') if (manifest.apiVersion < 3 && (manifest.contributes.conditions.length || agent || settings.modules.length || settings.groups.length || settings.items.length || manifest.permissions.agent || manifest.permissions.models || manifest.permissions.taskMessages || manifest.permissions.tasks !== 'none')) issue('Agent capabilities and settings require API 3') + const shells = manifest.contributes.shells + if ((shells || manifest.permissions.shells) && (manifest.apiVersion < 4 || !shells || !manifest.permissions.shells)) + issue('Shell contributions require API 4 and shells permission') + if (shells?.settingsGroup && !settings.groups.some(group => group.id === shells.settingsGroup)) + issue('Shell settingsGroup must reference an owned setting group') + for (const candidate of shells?.candidates ?? []) { + const enabled = settings.items.find(item => item.key === candidate.enabled) + const executable = settings.items.find(item => item.key === candidate.executable) + const priority = settings.items.find(item => item.key === candidate.priority) + if (shells?.settingsGroup && [enabled, executable, priority].some(item => item?.group !== shells.settingsGroup)) + issue('Shell candidate settings must belong to settingsGroup') + if (enabled?.type !== 'boolean' || enabled.default !== false) + issue('Shell enabled must reference an initially disabled boolean setting') + if (executable?.type !== 'string' || executable.default !== '') + issue('Shell executable must reference an empty-default string setting') + if (priority?.type !== 'number' || priority.default !== 0 || priority.min !== 0 || priority.max !== 100) + issue('Shell priority must reference a number setting with default 0 and range 0-100') + } if (agent && (!manifest.entry || !manifest.permissions.agent)) issue('Agent contributions require an entry and agent permission') if (typeof agent?.enabledWhen === 'string' && !settings.items.some(item => item.key === agent.enabledWhen && item.type === 'boolean')) @@ -214,6 +235,7 @@ export function extensionCompatible(manifest: ExtensionManifest, version: string export function addedExtensionPermissions(previous: ExtensionPermissions | undefined, next: ExtensionPermissions): string[] { return [ + ...(next.shells && !previous?.shells ? ['shells'] : []), ...(next.themeManagement && !previous?.themeManagement ? ['themeManagement'] : []), ...(next.taskMessages && !previous?.taskMessages ? ['taskMessages'] : []), ...(next.agent && !previous?.agent ? ['agent'] : []), diff --git a/apps/buddy/shared/extensions/extensionShell.ts b/apps/buddy/shared/extensions/extensionShell.ts new file mode 100644 index 000000000..98c5b2769 --- /dev/null +++ b/apps/buddy/shared/extensions/extensionShell.ts @@ -0,0 +1,35 @@ +import { z } from 'zod' +import { shellCandidateIdSchema, shellCandidateSchema } from '../shell/shellProgram.ts' +import { extensionSettingIdSchema, extensionSettingKeySchema } from './extensionSettings.ts' + +export const extensionShellContributionSchema = z.object({ + settingsGroup: extensionSettingIdSchema.optional(), + candidates: z.array(z.object({ + id: shellCandidateIdSchema, + enabled: extensionSettingKeySchema, + executable: extensionSettingKeySchema, + priority: extensionSettingKeySchema, + }).strict()).min(1).max(7), +}).strict().superRefine(({ candidates }, context) => { + if (new Set(candidates.map(candidate => candidate.id)).size !== candidates.length) + context.addIssue({ code: 'custom', message: 'Shell candidates must be unique' }) + const keys = candidates.flatMap(candidate => [candidate.enabled, candidate.executable, candidate.priority]) + if (new Set(keys).size !== keys.length) + context.addIssue({ code: 'custom', message: 'Shell settings must be distinct' }) +}) + +export type ExtensionShellContribution = z.infer + +export const extensionShellDescriptorSchema = z.object({ + extensionId: z.string().min(1).max(120), + revision: z.string().min(1), + configurationRevision: z.string().min(1), + candidates: z.array(shellCandidateSchema).max(7), + preferred: z.array(shellCandidateIdSchema).max(7), +}).strict() +export type ExtensionShellDescriptor = z.infer + +export const extensionShellRpc = { + list: 'host.extensions.shells.list', + changed: 'host.extensions.shells.changed', +} as const diff --git a/apps/buddy/shared/permissions/shellSandbox.ts b/apps/buddy/shared/permissions/shellSandbox.ts index 85d705203..8de443477 100644 --- a/apps/buddy/shared/permissions/shellSandbox.ts +++ b/apps/buddy/shared/permissions/shellSandbox.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { SHELL_SANDBOX_BACKEND } from '../platform/identifiers' +import { shellProgramSchema } from '../shell/shellProgram' const pathSchema = z.string().min(1).max(4_096).refine(path => /^(?:\/|[a-z]:\\)/i.test(path) && !path.includes('\0')) @@ -41,6 +42,7 @@ export type SandboxDirectoryGrant = z.infer export const sandboxCommandSchema = z.object({ command: z.string().min(1).max(1024 * 1024), + shell: shellProgramSchema.optional(), cwd: pathSchema, readOnly: z.boolean(), roots: z.array(pathSchema).min(1).max(128), diff --git a/apps/buddy/shared/shell/shellProgram.ts b/apps/buddy/shared/shell/shellProgram.ts new file mode 100644 index 000000000..485d8af50 --- /dev/null +++ b/apps/buddy/shared/shell/shellProgram.ts @@ -0,0 +1,24 @@ +import { z } from 'zod' + +export const shellDialectSchema = z.enum(['bash', 'zsh', 'sh', 'powershell', 'cmd']) +export type ShellDialect = z.infer + +export const shellProgramSchema = z.object({ + dialect: shellDialectSchema, + executable: z.string().min(1).max(4096).refine(value => /^(?:\/|[a-z]:[\\/])/i.test(value) && !value.includes('\0')), +}).strict() +export type ShellProgram = z.infer + +export const shellCandidateIdSchema = z.enum(['bash', 'git-bash', 'zsh', 'sh', 'powershell7', 'powershell51', 'cmd']) +export type ShellCandidateId = z.infer + +export const shellCandidateSchema = z.object({ + id: shellCandidateIdSchema, + label: z.string(), + dialect: shellDialectSchema, + executable: z.string(), + version: z.string(), + available: z.boolean(), + reason: z.string().optional(), +}).strict() +export type ShellCandidate = z.infer diff --git a/apps/buddy/shared/workbench/workbenchContributionCatalog.ts b/apps/buddy/shared/workbench/workbenchContributionCatalog.ts index f03d21405..c096bdc81 100644 --- a/apps/buddy/shared/workbench/workbenchContributionCatalog.ts +++ b/apps/buddy/shared/workbench/workbenchContributionCatalog.ts @@ -40,6 +40,7 @@ export const workbenchMenus = { export const workbenchDecorations = workbenchRegions satisfies Record export const workbenchRuntimeCapabilities = { + 'shells': { title: { 'zh-CN': '本机命令解释器', 'en-US': 'Local command interpreters' }, scope: 'application', description: 'API 4 contributes.shells declares fixed candidate IDs and native enabled/executable/priority setting keys. Requires permissions.shells. Host context.shells.detect() takes no arguments and reads bounded detection results, including unavailable candidates. Only user-enabled candidates reach core command execution under existing permissions; no plugin spawn, command input, configuration writes, installers, WSL or PTY. Defaults must leave candidates disabled, paths empty and priorities zero. See shells.md.' }, 'resources': { title: { 'zh-CN': '文件与媒体资源', 'en-US': 'File and media resources' }, scope: 'application', description: 'context.network.download(url,{name?,signal?}) streams approved HTTPS origins into persistent private file handles without media-size or total-duration quotas. context.resources.listFiles/readBytes/readText/release work in hosts and views; importFile copies an already-authorized local or Space file, or an owned private file, without another picker. Views use resources.getUrl for image/audio/video URLs with HEAD/Range/seek and openExternal for approved HTTPS links. localResources permits picking additional files; resourceExport separately permits user-selected export. No absolute paths or credentials. See protocol.md and api.d.ts.' }, 'window.styles': { title: { 'zh-CN': '窗口样式', 'en-US': 'Window styles' }, scope: 'application', description: 'API 3 host context.window.styles.set(id, css) injects a plugin-owned stylesheet into the whole application window; remove(id) withdraws it. Requires windowEffects. Supports ordinary CSS, stable [data-lexora-region] selectors and resources.getUrl({id}|{packagePath}) for owned files and package assets. Does not change theme selection. Same-ID updates replace the sheet, disable/crash/update withdraws all sheets. Ordering follows plugin ID then sheet ID, with standard CSS cascade. See protocol.md and api.d.ts.' }, 'themes': { title: { 'zh-CN': '主题与预览', 'en-US': 'Themes and previews' }, scope: 'application', description: 'API 3 contributes.themes provides multiple declarative light/dark themes without an entry. context.themes reads, validates and resolves documents; themeManagement grants user copies, import/export, preferences and owned revisioned previews. Colors, structured anchor materials and welcome text/images only; no host DOM access. task.welcome remains an independent content slot. Theme image sources accept existing resourceId or verified packagePath and are copied to theme-owned files without per-image approval. See themes.md and api.d.ts.' }, diff --git a/apps/buddy/src/modules/extensions/widgets/DesktopExtensionInstallReview.vue b/apps/buddy/src/modules/extensions/widgets/DesktopExtensionInstallReview.vue index 995f7c3e8..52a1c2e76 100644 --- a/apps/buddy/src/modules/extensions/widgets/DesktopExtensionInstallReview.vue +++ b/apps/buddy/src/modules/extensions/widgets/DesktopExtensionInstallReview.vue @@ -15,6 +15,7 @@ const english = computed(() => props.language === 'en-US') const permissions = computed(() => { const { permissions } = props.review.manifest return [ + ...(permissions.shells ? [{ key: 'shells', icon: Alert20Regular, title: english.value ? 'Detect local command interpreters' : '检测本机命令解释器', description: english.value ? 'Read interpreter paths and versions. Only interpreters you enable in native settings become model choices; Lexora executes commands under your existing permissions. The plugin cannot run commands or change executable paths.' : '读取解释器路径和版本。只有你在原生设置中启用的候选才可供模型选择,命令仍由 Lexora 按现有权限执行;插件不能执行命令或修改解释器路径。' }] : []), ...(permissions.agent ? [{ key: 'agent', icon: Alert20Regular, title: english.value ? 'Run plugin tools and actions' : '运行插件工具与动作', description: english.value ? 'Contribute model tools and run declared actions when task events occur or you request them.' : '提供模型工具,并在声明的任务事件发生或你主动请求时执行动作。' }] : []), ...(permissions.taskMessages ? [{ key: 'taskMessages', icon: Document20Regular, title: english.value ? 'Read task messages' : '读取任务消息', description: english.value ? 'Read a bounded text excerpt from the invoking task. Attachments and tool output are excluded.' : '读取触发任务的有限消息文本,不包含附件内容和工具输出。' }] : []), ...(permissions.models ? [{ key: 'models', icon: Alert20Regular, title: english.value ? 'Call configured models' : '调用已配置的模型', description: english.value ? 'Send tool-provided text to a configured model. Additional requests may incur usage costs. Credentials stay in Lexora.' : '将工具提供的文本发送给已配置的模型,额外请求可能产生费用;凭据不会交给插件。' }] : []), diff --git a/apps/buddy/src/modules/settings/model/settingsRegistry.ts b/apps/buddy/src/modules/settings/model/settingsRegistry.ts index 0c0d4d2bd..0b888dae1 100644 --- a/apps/buddy/src/modules/settings/model/settingsRegistry.ts +++ b/apps/buddy/src/modules/settings/model/settingsRegistry.ts @@ -1,5 +1,7 @@ import type { ExtensionSettingItem } from '@buddy-shared/extensions/extensionSettings' +import type { ExtensionShellContribution } from '@buddy-shared/extensions/extensionShell' import type { BuiltinSettingsCategory } from '@buddy-shared/settings/settingsCatalog' +import type { ShellCandidateId } from '@buddy-shared/shell/shellProgram' import type { BuddyLocale } from '@/i18n/buddyI18n' import { builtinSettingsModuleIds, publicSettingsGroups } from '@buddy-shared/settings/settingsCatalog' @@ -25,6 +27,7 @@ export interface SettingsGroup { title?: SettingsText order: number unframed?: boolean + shells?: { extensionId: string, revision: string, candidates: ExtensionShellContribution['candidates'] } } export interface PluginSettingField { @@ -33,6 +36,13 @@ export interface PluginSettingField { item: ExtensionSettingItem } +export interface ShellSettingsRow { + id: ShellCandidateId + enabled: PluginSettingField + executable: PluginSettingField + priority: PluginSettingField +} + export type SettingsItem = { id: string, group: string, order: number } & ( | { kind: 'content' } | { kind: 'general', field: GeneralSettingField } diff --git a/apps/buddy/src/modules/settings/state/__tests__/shellSettingsRegistration.spec.ts b/apps/buddy/src/modules/settings/state/__tests__/shellSettingsRegistration.spec.ts new file mode 100644 index 000000000..be48e1d13 --- /dev/null +++ b/apps/buddy/src/modules/settings/state/__tests__/shellSettingsRegistration.spec.ts @@ -0,0 +1,79 @@ +import type { ExtensionStatus } from '@buddy-shared/extensions/extensionApi' +import { extensionManifestSchema } from '@buddy-shared/extensions/extensionManifest' +import { describe, expect, it } from 'vitest' +import { effectScope, shallowRef } from 'vue' +import { useSettingsRegistry } from '../useSettingsRegistry' + +function plugin(): ExtensionStatus { + const id = 'tests.shell-settings' + return { + manifest: extensionManifestSchema.parse({ + schemaVersion: 1, + id, + name: 'Shells', + version: '1.0.0', + apiVersion: 4, + engines: { lexora: '*' }, + permissions: { shells: true }, + contributes: { + shells: { settingsGroup: `${id}.execution`, candidates: [{ id: 'bash', enabled: 'enabled', executable: 'executable', priority: 'priority' }] }, + settings: { + groups: [{ id: `${id}.execution`, module: 'settings.runtime', title: 'Shells' }], + items: [ + { id: `${id}.enabled`, group: `${id}.execution`, key: 'enabled', title: 'Enabled', type: 'boolean', default: false }, + { id: `${id}.executable`, group: `${id}.execution`, key: 'executable', title: 'Executable', type: 'string', default: '' }, + { id: `${id}.priority`, group: `${id}.execution`, key: 'priority', title: 'Priority', type: 'number', min: 0, max: 100, default: 0 }, + ], + }, + }, + }), + revision: 'one', + enabled: true, + development: false, + compatible: true, + pending: null, + state: 'inactive', + generation: null, + error: null, + activationMs: null, + logs: [], + } +} + +describe('shell settings registration', () => { + it('registers the declared native table in Runtime with its original configuration keys and owner', () => { + const scope = effectScope() + try { + const installed = shallowRef([plugin()]) + const registry = scope.run(() => useSettingsRegistry(installed))! + const id = installed.value[0]!.manifest.id + const runtime = registry.modules.value.find(module => module.id === 'settings.runtime')! + const group = runtime.groups.find(group => group.owner === id)! + expect(group.shells).toEqual({ extensionId: id, revision: 'one', candidates: [{ id: 'bash', enabled: 'enabled', executable: 'executable', priority: 'priority' }] }) + expect(group.items.map(item => item.kind === 'plugin' && item.field.item.key).sort()).toEqual(['enabled', 'executable', 'priority']) + expect(registry.extensionLocation(id)).toEqual({ name: 'desktop.settings.runtime', query: { group: `${id}.execution` } }) + expect(registry.modules.value.some(module => module.owner === id)).toBe(false) + } + finally { scope.stop() } + }) + + it('revokes the table on disable, incompatibility and removal, and refreshes package-bound fields after update', () => { + const scope = effectScope() + try { + const original = plugin() + const installed = shallowRef([original]) + const registry = scope.run(() => useSettingsRegistry(installed))! + const groups = () => registry.modules.value.flatMap(module => module.groups).filter(group => group.owner === original.manifest.id) + installed.value = [{ ...original, enabled: false }] + expect(groups()).toEqual([]) + installed.value = [{ ...original, compatible: false }] + expect(groups()).toEqual([]) + installed.value = [{ ...original, revision: 'two' }] + expect(groups()[0]!.shells?.revision).toBe('two') + expect(groups()[0]!.items.every(item => item.kind === 'plugin' && item.field.revision === 'two')).toBe(true) + installed.value = [] + expect(groups()).toEqual([]) + } + finally { scope.stop() } + }) +}) diff --git a/apps/buddy/src/modules/settings/state/__tests__/usePluginSettings.spec.ts b/apps/buddy/src/modules/settings/state/__tests__/usePluginSettings.spec.ts index e1bc97ffb..eb689cd0b 100644 --- a/apps/buddy/src/modules/settings/state/__tests__/usePluginSettings.spec.ts +++ b/apps/buddy/src/modules/settings/state/__tests__/usePluginSettings.spec.ts @@ -46,6 +46,8 @@ function fixture() { const reads: ReturnType>[] = [] const writes: ReturnType>[] = [] const store = scope.run(() => usePluginSettings(installed, { + detectShells: async () => [], + selectShellExecutable: async () => null, settingConditions: async () => ({}), onConditionsChanged: () => () => {}, configurationSnapshot: () => { diff --git a/apps/buddy/src/modules/settings/state/usePluginSettings.ts b/apps/buddy/src/modules/settings/state/usePluginSettings.ts index 598ef7952..9590f572a 100644 --- a/apps/buddy/src/modules/settings/state/usePluginSettings.ts +++ b/apps/buddy/src/modules/settings/state/usePluginSettings.ts @@ -13,7 +13,7 @@ interface ConfigurationSession { loading: boolean } -export function usePluginSettings(installed: Readonly>, api: Pick) { +export function usePluginSettings(installed: Readonly>, api: Pick) { const snapshots = shallowRef>({}) const configurations = computed>(() => Object.fromEntries(Object.entries(snapshots.value).map(([id, snapshot]) => [id, snapshot.values]))) const conditions = usePluginSettingConditions(api, configurations) @@ -120,6 +120,6 @@ export function usePluginSettings(installed: Readonly>, a syncPending() } } - return { conditions, configurations, invalidKeys, errors, pending, loading, save, reload } + return { conditions, configurations, invalidKeys, errors, pending, loading, save, reload, detectShells: api.detectShells, selectShellExecutable: api.selectShellExecutable } } export type PluginSettings = ReturnType diff --git a/apps/buddy/src/modules/settings/state/useSettingsRegistry.ts b/apps/buddy/src/modules/settings/state/useSettingsRegistry.ts index 2de72b625..187a914f4 100644 --- a/apps/buddy/src/modules/settings/state/useSettingsRegistry.ts +++ b/apps/buddy/src/modules/settings/state/useSettingsRegistry.ts @@ -24,13 +24,13 @@ export function useSettingsRegistry(installed: Readonly>) } } for (const plugin of active) { - const { id, contributes: { settings } } = plugin.manifest - const fingerprint = JSON.stringify([plugin.revision, settings]) + const { id, contributes: { settings, shells } } = plugin.manifest + const fingerprint = JSON.stringify([plugin.revision, settings, shells]) if (registrations.get(id)?.fingerprint === fingerprint) continue const dispose = registry.register(id, { modules: settings.modules.map(module => ({ ...module, section: 'plugins', requiresRuntime: false })), - groups: settings.groups, + groups: settings.groups.map(group => ({ ...group, ...(shells?.settingsGroup === group.id ? { shells: { extensionId: id, revision: plugin.revision, candidates: shells.candidates } } : {}) })), items: settings.items.map(item => ({ id: item.id, group: item.group, order: item.order, kind: 'plugin', field: { extensionId: id, revision: plugin.revision, item } })), }) registrations.set(id, { fingerprint, dispose }) diff --git a/apps/buddy/src/modules/settings/widgets/plugins/DesktopPluginSetting.vue b/apps/buddy/src/modules/settings/widgets/plugins/DesktopPluginSetting.vue index f0f9ff566..43ff1f741 100644 --- a/apps/buddy/src/modules/settings/widgets/plugins/DesktopPluginSetting.vue +++ b/apps/buddy/src/modules/settings/widgets/plugins/DesktopPluginSetting.vue @@ -1,31 +1,35 @@ diff --git a/apps/buddy/src/modules/settings/widgets/plugins/DesktopPluginSettingField.vue b/apps/buddy/src/modules/settings/widgets/plugins/DesktopPluginSettingField.vue index 9ef54301a..abb3532cc 100644 --- a/apps/buddy/src/modules/settings/widgets/plugins/DesktopPluginSettingField.vue +++ b/apps/buddy/src/modules/settings/widgets/plugins/DesktopPluginSettingField.vue @@ -4,11 +4,14 @@ import type { LocalProvider, LocalRuntimeModelOption } from '@buddy-shared/provi import type { BuddyLocale } from '@/i18n/buddyI18n' import { NButton, NInput, NInputNumber, NSelect, NSwitch } from 'naive-ui' import { shallowRef, watch } from 'vue' +import DesktopSettingCell from '../shared/DesktopSettingCell.vue' import DesktopSettingRow from '../shared/DesktopSettingRow.vue' import DesktopPluginModelSetting from './DesktopPluginModelSetting.vue' const props = defineProps<{ item: ExtensionSettingItem + compact?: boolean + error?: string | null value: ExtensionSettingValue disabled: boolean saving?: boolean @@ -36,7 +39,7 @@ function commit(value: ExtensionSettingValue) { diff --git a/apps/buddy/src/modules/settings/widgets/registry/DesktopSettingsGroups.vue b/apps/buddy/src/modules/settings/widgets/registry/DesktopSettingsGroups.vue index 33b8c6034..09abbaea8 100644 --- a/apps/buddy/src/modules/settings/widgets/registry/DesktopSettingsGroups.vue +++ b/apps/buddy/src/modules/settings/widgets/registry/DesktopSettingsGroups.vue @@ -6,6 +6,7 @@ import { settingsText } from '../../model/settingsRegistry' import { useSettingsContext } from '../../settingsContext' import DesktopGeneralSettingField from '../app/DesktopGeneralSettingField.vue' import DesktopPluginSetting from '../plugins/DesktopPluginSetting.vue' +import DesktopShellSettingsTable from '../runtime/DesktopShellSettingsTable.vue' import DesktopSettingsGroup from '../shared/DesktopSettingsGroup.vue' defineProps<{ groups: readonly SettingsGroupNode[], fill?: boolean }>() @@ -13,6 +14,10 @@ defineSlots<{ default?: () => unknown, status?: () => unknown }>() const { applicationSettings: { language } } = useSettingsContext() const route = useRoute() const sections = useTemplateRef('sections') +function remainingItems(group: SettingsGroupNode) { + const keys = new Set(group.shells?.candidates.flatMap(candidate => [candidate.enabled, candidate.executable, candidate.priority])) + return group.items.filter(item => item.kind !== 'plugin' || !keys.has(item.field.item.key)) +} let focused: HTMLElement | null = null watchPostEffect(() => { const target = sections.value?.find(section => section.dataset.settingsGroup === route.query.group) @@ -35,11 +40,12 @@ watchPostEffect(() => { : 'grid', ]" :data-settings-group="group.id" > -

+

{{ settingsText(group.title, language) }}

-