Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/buddy/electron/main/app/DesktopIntegrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand All @@ -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))
Expand Down
9 changes: 9 additions & 0 deletions apps/buddy/electron/main/app/DesktopRuntimeHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -45,6 +46,8 @@ export class DesktopRuntimeHost {
readonly performance: DesktopPerformanceMonitor
inspectExtension: ((id: string) => Promise<ExtensionInspection>) | null = null
extensionAgent: ReturnType<typeof registerExtensionIpc>['agent'] | null = null
extensionShells: ReturnType<typeof registerExtensionIpc>['shells'] | null = null
extensionStorage: Pick<ReturnType<typeof registerExtensionIpc>, 'withStoppedStorage'> | null = null
readonly contextPanel: ContextPanelHost
readonly configStore: LexoraConfigStore
readonly #environment: DesktopEnvironment
Expand Down Expand Up @@ -81,6 +84,11 @@ export class DesktopRuntimeHost {
return this.#config
}

withStoppedStorage<T>(operation: () => Promise<T>): Promise<T> {
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'
}
Expand Down Expand Up @@ -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)
Expand Down
24 changes: 23 additions & 1 deletion apps/buddy/electron/main/extensions/registerExtensionIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,12 @@ export function registerExtensionIpc(options: {
notificationsEnabled?: () => boolean
taskActions: () => Promise<ExtensionTaskAction[]>
invokeTaskAction: (input: ExtensionTaskActionInput) => Promise<ExtensionTaskActionResult>
shellsChanged?: () => void
agentChanged?: (catalog: ExtensionAgentCatalog) => void
record?: ApplicationDiagnosticReporter
conditionRuntime?: (input: { models: boolean, task: boolean, taskId: string | null, runId: string | null }, signal: AbortSignal) => Promise<ExtensionConditionRuntime>
agentRequest?: (input: { invocationId: string, method: string, params: JsonValue }, signal: AbortSignal) => Promise<JsonValue>
}): { themesReady: Promise<void>, conditions: ExtensionService['conditions'], dispose: () => Promise<void>, reviewPackage: (path: string) => Promise<void>, inspect: (id: string) => Promise<ExtensionInspection>, agent: { list: () => Promise<ExtensionAgentDescriptor[]>, invoke: (input: ExtensionAgentInvocation, signal: AbortSignal) => Promise<JsonValue> } } {
}): { shells: { list: ExtensionService['shellContributions'] }, withStoppedStorage: ExtensionService['withStoppedStorage'], themesReady: Promise<void>, conditions: ExtensionService['conditions'], dispose: () => Promise<void>, reviewPackage: (path: string) => Promise<void>, inspect: (id: string) => Promise<ExtensionInspection>, agent: { list: () => Promise<ExtensionAgentDescriptor[]>, invoke: (input: ExtensionAgentInvocation, signal: AbortSignal) => Promise<JsonValue> } } {
const store = new ExtensionPackageStore(join(options.home, 'extensions'), options.version)
async function reviewResult(operation: () => Promise<ExtensionReview>): Promise<ExtensionReviewResult> {
try {
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof registerExtensionIpc>['shells'] | null) {
return peer.onRequest(extensionShellRpc.list, () => getShells()?.list() ?? [])
}
1 change: 1 addition & 0 deletions apps/buddy/electron/main/extensions/runtime/host.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 1 addition & 1 deletion apps/buddy/electron/main/storage/StorageBackupService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {})
Expand Down
2 changes: 2 additions & 0 deletions apps/buddy/electron/preload/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
52 changes: 52 additions & 0 deletions apps/buddy/native/host/__tests__/shell_program.rs
Original file line number Diff line number Diff line change
@@ -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 '你好'")
);
}
3 changes: 2 additions & 1 deletion apps/buddy/native/host/src/shell_sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ mod policy;
pub(crate) mod process;
mod security;
mod setup;
mod shell_program;
mod token;

use std::io;
Expand All @@ -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<i32> {
let arguments: Vec<String> = std::env::args().skip(1).collect();
Expand Down
54 changes: 30 additions & 24 deletions apps/buddy/native/host/src/shell_sandbox/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -38,6 +39,8 @@ struct Request {
command: String,
cwd: String,
shell: String,
#[serde(default)]
dialect: Dialect,
private_root: String,
environment: BTreeMap<String, String>,
grants: Vec<Grant>,
Expand Down Expand Up @@ -113,25 +116,23 @@ pub(super) fn run() -> io::Result<i32> {
),
)?;
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,
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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<String>,
pub(super) script: String,
pub(super) cwd: String,
pub(super) environment: BTreeMap<String, String>,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<u16> = request
.environment
.iter()
Expand Down
Loading
Loading