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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ jobs:
- run: npm run test:app-lifetime -w @conn/ui
- run: npm run test:collaboration
- run: npm run test:input -w @conn/collaboration-tests
- run: npm run test:regressions -w @conn/collaboration-tests
- run: npm run package:web -- --debug
required:
name: Required checks
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## Unreleased

- Keep agent connections accessible through a compact session-header summary and a keyboard-accessible list. Preserve distinct connections with repeated names, show full long names, and keep the close control visible.
- Remove inherited AppImage runtime paths from host-side terminal children while preserving host paths and explicit profile overrides. Host commands such as Flatpak no longer load the desktop bundle's libraries.
- Add production UI/PTY/MCP regressions for empty Readline edits, retained human input, connection overflow, resizing, disconnects and session controls. Make the Readline fixture independent of the test runner's terminal type.

한국어: 세션 헤더의 에이전트 연결을 대표 이름과 추가 연결 수로 요약하고, 키보드로 열 수 있는 목록에서 긴 이름과 같은 이름의 개별 연결을 확인할 수 있게 했습니다. 닫기 버튼을 보존하고, AppImage 실행 경로가 공유 셸에 유출되지 않도록 사용자 경로·프로필 재정의를 유지하며 복원합니다. 빈 입력·남은 인간 입력·배지 overflow·창 크기·연결 해제·세션 조작의 실제 UI/PTY/MCP 회귀 검증을 추가했습니다.

## 0.8.8 — Preview · 2026-09-23

- Keep the complete agent command through supported cursor edits, including wrapped input and colored/wide prompts. History, completion and uncertain edits no longer let a screen-row fragment determine approval for a different physical command.
Expand Down
2 changes: 2 additions & 0 deletions crates/core/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ use crate::policy::PolicyStore;
use crate::session::{EventSink, Session, SessionConfig, SharedSession};

mod child_killer;
mod child_environment;
use child_killer::ChildKiller;

/// Move-owned startup specification for trusted native callers. Never serialized.
Expand Down Expand Up @@ -149,6 +150,7 @@ impl Engine {
let shell = profile.program.clone();
let mut cmd = CommandBuilder::new(&plan.program);
cmd.args(&plan.args);
child_environment::restore_host_environment(&mut cmd);
cmd.env("CONN", "1");
cmd.env("TERM", std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".into()));
for (k, v) in &cfg.env { cmd.env(k, v); }
Expand Down
144 changes: 144 additions & 0 deletions crates/core/src/engine/child_environment.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
//! AppImage's runtime paths belong to the desktop, not its host-side PTY child.
//! Restore inherited paths before applying explicit engine/profile overrides.
use portable_pty::CommandBuilder;

pub(super) fn restore_host_environment(cmd: &mut CommandBuilder) {
#[cfg(target_os = "linux")]
restore_appimage_paths(cmd);
#[cfg(not(target_os = "linux"))]
let _ = cmd;
}

#[cfg(target_os = "linux")]
fn restore_appimage_paths(cmd: &mut CommandBuilder) {
use std::path::{Component, Path, PathBuf};
fn normalized(path: &Path) -> PathBuf {
let mut result = PathBuf::new();
for part in path.components() {
match part {
Component::ParentDir => {
result.pop();
}
Component::CurDir => {}
_ => result.push(part.as_os_str()),
}
}
result
}
if cmd.get_env("APPIMAGE").is_none_or(|value| value.is_empty()) {
return;
}
let Some(appdir) = cmd
.get_env("APPDIR")
.map(PathBuf::from)
.filter(|path| path.is_absolute())
else {
return;
};
let appdir = normalized(&appdir);
// A malformed root must never strip the host's entire environment.
if appdir.parent().is_none() {
return;
}
let bundled = |path: &Path| path.is_absolute() && normalized(path).starts_with(&appdir);
for key in [
"LD_LIBRARY_PATH",
"PATH",
"XDG_DATA_DIRS",
"GI_TYPELIB_PATH",
"GTK_PATH",
"GIO_EXTRA_MODULES",
] {
let Some(value) = cmd.get_env(key) else {
continue;
};
let paths: Vec<_> = std::env::split_paths(value).collect();
if !paths.iter().any(|path| bundled(path)) {
continue;
}
let retained: Vec<_> = paths.into_iter().filter(|path| !bundled(path)).collect();
if retained.is_empty() {
cmd.env_remove(key);
} else if let Ok(value) = std::env::join_paths(retained) {
cmd.env(key, value);
}
}
for key in [
"GTK_DATA_PREFIX",
"GTK_EXE_PREFIX",
"GSETTINGS_SCHEMA_DIR",
"GTK_IM_MODULE_FILE",
"GDK_PIXBUF_MODULE_FILE",
"GDK_PIXBUF_MODULEDIR",
] {
if cmd
.get_env(key)
.is_some_and(|value| bundled(Path::new(value)))
{
cmd.env_remove(key);
}
}
}

#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::*;
fn appimage() -> CommandBuilder {
let mut cmd = CommandBuilder::new("/bin/sh");
cmd.env_clear();
cmd.env("APPIMAGE", "/opt/Conn.AppImage");
cmd.env("APPDIR", "/tmp/conn-bundle");
cmd
}
#[test]
fn keeps_host_paths_empty_segments_and_similar_prefixes() {
let mut cmd = appimage();
cmd.env("LD_LIBRARY_PATH", "/tmp/conn-bundle/usr/lib:/opt/user/lib::/tmp/conn-bundle-other/lib:/tmp/conn-bundle/../host/lib");
restore_host_environment(&mut cmd);
assert_eq!(
cmd.get_env("LD_LIBRARY_PATH").unwrap(),
"/opt/user/lib::/tmp/conn-bundle-other/lib:/tmp/conn-bundle/../host/lib"
);
}
#[test]
fn removes_bundle_only_runtime_settings() {
let mut cmd = appimage();
cmd.env(
"LD_LIBRARY_PATH",
"/tmp/conn-bundle//usr/lib:/tmp/conn-bundle/usr/lib/../lib64",
);
cmd.env("GIO_EXTRA_MODULES", "/tmp/conn-bundle/usr/lib/gio/modules");
cmd.env(
"GTK_IM_MODULE_FILE",
"/tmp/conn-bundle/usr/lib/immodules.cache",
);
cmd.env("GSETTINGS_SCHEMA_DIR", "/opt/user/schemas");
cmd.env("PATH", "/tmp/conn-bundle/usr/bin:/usr/local/bin:/usr/bin");
restore_host_environment(&mut cmd);
assert!(cmd.get_env("LD_LIBRARY_PATH").is_none());
assert!(cmd.get_env("GIO_EXTRA_MODULES").is_none());
assert!(cmd.get_env("GTK_IM_MODULE_FILE").is_none());
assert_eq!(
cmd.get_env("GSETTINGS_SCHEMA_DIR").unwrap(),
"/opt/user/schemas"
);
assert_eq!(cmd.get_env("PATH").unwrap(), "/usr/local/bin:/usr/bin");
}
#[test]
fn ordinary_launches_and_malformed_appdirs_are_unchanged() {
for appdir in [None, Some("/"), Some("relative/bundle")] {
let mut cmd = appimage();
cmd.env("LD_LIBRARY_PATH", "/tmp/conn-bundle/usr/lib:/opt/user/lib");
if let Some(path) = appdir {
cmd.env("APPDIR", path);
} else {
cmd.env_remove("APPIMAGE");
}
restore_host_environment(&mut cmd);
assert_eq!(
cmd.get_env("LD_LIBRARY_PATH").unwrap(),
"/tmp/conn-bundle/usr/lib:/opt/user/lib"
);
}
}
}
85 changes: 85 additions & 0 deletions crates/core/tests/child_environment.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#![cfg(target_os = "linux")]
mod common;
use conn_core::{
audit::Audit,
backend::Profile,
policy::{Policy, PolicyStore},
Engine, EngineConfig,
};
use std::{
process::Command,
time::{Duration, Instant},
};

// Each case runs in its own process: never mutate global environment alongside
// other PTY tests. Check what an actual Engine child receives, including overrides.
#[test]
fn appimage_host_shell_environment_and_profile_overrides() {
for case in ["inherited", "profile", "ordinary"] {
let mut child = Command::new(std::env::current_exe().unwrap());
child
.args([
"--exact",
"child_environment_fixture",
"--ignored",
"--nocapture",
])
.env("CONN_ENV_TEST_CASE", case)
.env("APPDIR", "/tmp/conn-bundle")
.env("APPIMAGE", "/opt/Conn.AppImage")
.env(
"LD_LIBRARY_PATH",
"/tmp/conn-bundle/usr/lib:/opt/user-lib::/opt/another-lib",
);
if case == "ordinary" {
child.env_remove("APPIMAGE");
}
let output = child.output().unwrap();
assert!(
output.status.success(),
"{case}: {}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
}

#[test]
#[ignore = "launched by the parent with a dedicated environment"]
fn child_environment_fixture() {
let case = std::env::var("CONN_ENV_TEST_CASE").unwrap();
let mut profile = Profile::local("environment-test".into(), "/bin/sh".into());
profile.args = vec![
"-c".into(),
"printf 'SHELL_LD=<%s>\\n' \"${LD_LIBRARY_PATH-UNSET}\"".into(),
];
if case == "profile" {
profile
.env
.insert("LD_LIBRARY_PATH".into(), "/opt/profile-lib".into());
}
let output: common::Buf = Default::default();
let engine = Engine::spawn(EngineConfig {
profile: Some(profile),
audit: Some(Audit::null()),
policy: Some(PolicyStore::from_policy(
Policy::parse("default: deny\n").unwrap(),
)),
output_frame: Some(common::frame_sink(&output)),
..Default::default()
})
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while !engine.has_exited() {
assert!(Instant::now() < deadline, "child did not exit");
std::thread::sleep(Duration::from_millis(10));
}
assert_eq!(engine.wait(), Some(0));
let expected = match case.as_str() {
"profile" => "/opt/profile-lib",
"ordinary" => "/tmp/conn-bundle/usr/lib:/opt/user-lib::/opt/another-lib",
_ => "/opt/user-lib::/opt/another-lib",
};
assert!(String::from_utf8_lossy(&output.lock().unwrap())
.contains(&format!("SHELL_LD=<{expected}>")));
}
2 changes: 2 additions & 0 deletions crates/core/tests/shell_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ impl Shell {
vec!["-i".into()]
};
profile.env.insert("PS1".into(), "conn-test$ ".into());
// Exercise Readline terminal keys independently of the test runner's TERM.
profile.env.insert("TERM".into(), "xterm-256color".into());
profile.env.insert("HISTFILE".into(), "/dev/null".into());
profile.env.insert("PROMPT_COMMAND".into(), prompt.into());
profile.env.insert("HISTCONTROL".into(), "".into());
Expand Down
107 changes: 107 additions & 0 deletions packages/ui/src/components/SessionAgents.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<script lang="ts">
import { tick } from 'svelte';
import { useConnApp } from '../runtime/context';
import { agentColor } from '../lib/themes';
const app = useConnApp();
const { st, tab, t } = app;
let { session }: { session: string } = $props();
const terminal = $derived(tab(session));
const connections = $derived(terminal?.shared ? st.activity.connections.filter(connection => connection.session === session) : []);
const representative = $derived(connections.find(connection => terminal?.controller.type === 'agent' && connection.agentId === terminal.controller.agentId) ?? connections[0]);
let open = $state(false);
let trigger = $state<HTMLButtonElement>();
let popup = $state<HTMLDivElement>();
let tabTrigger: HTMLButtonElement | undefined;
let left = $state(16);
let top = $state(44);
let activeWhenOpened = '';
const popupId = $derived(`session-agents-${session}`);

function close(restore = false) {
const fallback = tabTrigger;
open = false;
if (restore) void tick().then(() => (trigger?.isConnected ? trigger : fallback)?.focus());
}
function position() {
if (!app.root || !trigger) return;
const root = app.root.getBoundingClientRect(), anchor = trigger.getBoundingClientRect();
const width = Math.min(360, root.width - 32);
left = Math.max(16, Math.min(anchor.left - root.left, root.width - width - 16));
top = anchor.bottom - root.top + 8;
}
async function show() {
if (!trigger) return;
activeWhenOpened = st.active;
tabTrigger = trigger.closest('.tab-item')?.querySelector<HTMLButtonElement>('[role="tab"]') ?? undefined;
position(); open = true;
await tick(); popup?.focus();
}
// The tab strip scrolls; render details in this app's root so they cannot be
// clipped by that scroll container or inherit another app's theme/state.
function portal(node: HTMLDivElement) {
app.root?.appendChild(node);
const list = trigger?.closest('[role="tablist"]');
const moved = () => close(true);
list?.addEventListener('scroll', moved);
return { destroy() { list?.removeEventListener('scroll', moved); node.remove(); } };
}
function outside(event: PointerEvent) {
if (open && !trigger?.contains(event.target as Node) && !popup?.contains(event.target as Node)) close();
}
function focusout(event: FocusEvent) {
const next = event.relatedTarget as Node | null;
if (open && next && !trigger?.contains(next) && !popup?.contains(next)) close();
}
function key(event: KeyboardEvent) {
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(true); }
// This is a read-only, non-modal popup. Continue normal header tab order.
else if (event.key === 'Tab') {
event.preventDefault();
const next = trigger?.closest('.tab-item')?.querySelector<HTMLButtonElement>(event.shiftKey ? '[role="tab"]' : '.x');
close(); void tick().then(() => next?.focus());
}
}
$effect(() => { if (open && (!connections.length || st.active !== activeWhenOpened)) close(true); });
</script>

<svelte:window onpointerdown={outside} onresize={() => { if (open) position(); }} />
{#if representative}
<div class="agents" onfocusout={focusout}>
<button class="trigger" bind:this={trigger} aria-haspopup="dialog" aria-expanded={open} aria-controls={open ? popupId : undefined}
aria-label={t('tab.agents.open', {n: connections.length, tab: terminal?.title ?? ''})}
title={t('tab.agents.open', {n: connections.length, tab: terminal?.title ?? ''})}
style:--a={agentColor(representative.agentId)} onclick={() => open ? close(true) : show()}>
<span class="name">{representative.agentId}</span>
{#if connections.length > 1}<span class="count">+{connections.length - 1}</span>{/if}
</button>
</div>
{/if}
{#if open}
<div use:portal bind:this={popup} id={popupId} class="connections conn-popover" role="dialog" aria-labelledby={`${popupId}-title`}
tabindex="-1" style:left={`${left}px`} style:top={`${top}px`} onkeydown={key} onfocusout={focusout}>
<header><h2 id={`${popupId}-title`}>{t('tab.agents.title', {n: connections.length})}</h2><button class="dismiss" aria-label={t('close')} onclick={() => close(true)}>×</button></header>
<p class="control">{terminal?.controller.type === 'agent' ? t('conn.agent', {agent: terminal.controller.agentId ?? ''}) : t('conn.yours')}</p>
<ul>{#each connections as connection (connection.connId)}
<li><span class="agent-name" style:--a={agentColor(connection.agentId)}>{connection.agentId}</span><small>{t('tab.agents.connection', {n: connection.connId})}</small></li>
{/each}</ul>
</div>
{/if}

<style>
.agents { flex: 0 1 auto; min-width: 0; max-width: 132px; }
.trigger { display: flex; align-items: center; gap: 5px; max-width: 100%; min-width: 0; height: 20px; padding: 0 6px; border: 1px solid color-mix(in srgb, var(--a) 45%, transparent); border-radius: 999px; background: transparent; color: var(--a); font-size: 10px; cursor: pointer; }
.name { min-width: 0; max-width: 88px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.count { flex-shrink: 0; color: var(--fg); font-variant-numeric: tabular-nums; }
.trigger:hover, .trigger[aria-expanded="true"] { background: var(--surface2); }
.trigger:focus-visible, .dismiss:focus-visible { outline: 2px solid var(--agent); outline-offset: 2px; }
.connections { position: absolute; z-index: 30; width: min(360px, calc(100% - 32px)); max-height: calc(100% - 64px); padding: 12px 14px; overflow-y: auto; font-size: 12px; outline: none; --popover-origin: 24px -10px; }
header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
h2 { margin: 0; font-size: 13px; }
.dismiss { flex-shrink: 0; width: 26px; height: 26px; border: 0; border-radius: 4px; background: transparent; color: var(--muted); font-size: 16px; cursor: pointer; }
.dismiss:hover { background: var(--surface2); color: var(--fg); }
.control { margin: 6px 0 10px; color: var(--muted); overflow-wrap: anywhere; }
ul { list-style: none; padding: 0; margin: 0; }
li { display: flex; align-items: flex-start; gap: 12px; padding: 9px 0; border-top: 1px solid var(--line); }
.agent-name { flex: 1; min-width: 0; color: var(--a); overflow-wrap: anywhere; }
small { flex-shrink: 0; padding-top: 1px; color: var(--muted); font-size: 10px; font-variant-numeric: tabular-nums; }
</style>
Loading
Loading