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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@

## [Unreleased]

## [v0.51.558] — 2026-06-21 — Release TQ (workspace tree shows symlinks that point outside the workspace)

### Added

- **The workspace file tree now shows symlinks whose target resolves outside the workspace, as display-only rows.** Previously these were silently dropped, so a link in your workspace simply didn't appear. They now show with an indicator that they point outside the workspace; they remain non-navigable (the read/list gate still blocks traversing through them), and the row deliberately does **not** disclose the resolved outside path or any target metadata. Thanks @claw-io.

### Fixed

- **A prompt dialog (rename / new file) could open without its Cancel button** right after viewing an outside-symlink's info dialog. The shared dialog's Cancel button is now always restored.

## [v0.51.557] — 2026-06-21 — Release TP (jump-to-question stays discoverable on desktop)

### Changed
Expand Down
57 changes: 40 additions & 17 deletions api/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -1220,32 +1220,54 @@ def _process(name, is_symlink, raw_link, lstat_result, reachable):
return # target is under link_target — ancestor → cycle
except ValueError:
pass
# Hide symlinks that resolve outside the workspace (can never be opened).
# Tag symlinks whose resolved target escapes the workspace root.
# Previously silently dropped; now emitted with target_outside_workspace=True
# so the workspace tree can show the link exists (display-only — the
# read/list gate in safe_resolve_ws / open_anchored_fd still blocks
# navigation through it).
target_outside_workspace = False
try:
link_target.relative_to(ws_resolved)
except ValueError:
return
target_outside_workspace = True
if _is_blocked_system_path(link_target):
return
is_dir = link_target.is_dir()
display_path = name
if rel and rel != '.':
display_path = rel + '/' + display_path
mtime_ns = lstat_result.st_mtime_ns if lstat_result is not None else None
entry = {
'name': name,
'path': display_path,
'type': 'symlink',
'target': str(link_target),
'is_dir': is_dir,
'mtime_ns': mtime_ns,
}
if not is_dir:
try:
entry['size'] = link_target.stat().st_size
except OSError:
entry['size'] = None
entries.append(entry)
if target_outside_workspace:
# #4581 hardening: a display-only escape-target symlink must NOT
# disclose where it points. Emit ONLY display-safe fields — never
# the resolved outside path, target-derived is_dir, or target size
# (the row exists to show the link is present; navigation/read
# through it stays blocked by safe_resolve_ws/open_anchored_fd).
entry = {
'name': name,
'path': display_path,
'type': 'symlink',
'is_dir': False,
'target_outside_workspace': True,
'mtime_ns': mtime_ns,
}
entries.append(entry)
else:
is_dir = link_target.is_dir()
entry = {
'name': name,
'path': display_path,
'type': 'symlink',
'target': str(link_target),
'is_dir': is_dir,
'target_outside_workspace': False,
'mtime_ns': mtime_ns,
}
if not is_dir:
try:
entry['size'] = link_target.stat().st_size
except OSError:
entry['size'] = None
entries.append(entry)
else:
entry_path = name
if rel and rel != '.':
Expand Down Expand Up @@ -1381,6 +1403,7 @@ def dir_signature(workspace: Path, rel: str = '.', entries: list[dict] | None =
'size': entry.get('size'),
'mtime_ns': entry.get('mtime_ns'),
'target': entry.get('target'),
'target_outside_workspace': entry.get('target_outside_workspace'),
})
raw = json.dumps(payload, sort_keys=True, separators=(',', ':'), ensure_ascii=False)
return hashlib.sha256(raw.encode('utf-8')).hexdigest()
Expand Down
13 changes: 13 additions & 0 deletions static/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,7 @@ const LOCALES = {
downloading: (name) => `Downloading ${name}\u2026`,
double_click_rename: 'Double-click to rename',
symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Renamed to ',
rename_failed: 'Rename failed: ',
Expand Down Expand Up @@ -1988,6 +1989,7 @@ const LOCALES = {
downloading: (name) => `Scaricamento ${name}\u2026`,
double_click_rename: 'Doppio clic per rinominare',
symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Rinominato in ',
rename_failed: 'Rinomina fallita: ',
Expand Down Expand Up @@ -3524,6 +3526,7 @@ const LOCALES = {
downloading: (name) => `${name} をダウンロード中…`,
double_click_rename: 'ダブルクリックで名前変更',
symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
session_rename_failed_no_row: '名前変更を開始できませんでした — 行が見つかりません。',
renamed_to: '名前を変更: ',
rename_failed: '名前変更失敗: ',
Expand Down Expand Up @@ -4984,6 +4987,7 @@ const LOCALES = {
downloading: (name) => `Скачиваю ${name}…`,
double_click_rename: 'Дважды щёлкните, чтобы переименовать',
symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Переименовано в ',
rename_failed: 'Не удалось переименовать: ',
Expand Down Expand Up @@ -6437,6 +6441,7 @@ const LOCALES = {
downloading: (name) => `Descargando ${name}…`,
double_click_rename: 'Haz doble clic para renombrar',
symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Renombrado a ',
rename_failed: 'Error al renombrar: ',
Expand Down Expand Up @@ -7888,6 +7893,7 @@ const LOCALES = {
downloading: (name) => `Lade ${name} herunter\u2026`,
double_click_rename: 'Doppelklick zum Umbenennen',
symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Umbenannt in ',
rename_failed: 'Umbenennen fehlgeschlagen: ',
Expand Down Expand Up @@ -9404,6 +9410,7 @@ const LOCALES = {
downloading: (name) => `正在下载 ${name}...`,
double_click_rename: '双击重命名',
symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: '已重命名为 ',
rename_failed: '重命名失败:',
Expand Down Expand Up @@ -10954,6 +10961,7 @@ const LOCALES = {
downloading: (name) => `正在下載 ${name}…`,
double_click_rename: '按兩下即可重新命名',
symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
session_rename_failed_no_row: '無法開始重新命名,找不到資料列。',
renamed_to: '已重新命名為 ',
rename_failed: '重新命名失敗:',
Expand Down Expand Up @@ -12393,6 +12401,7 @@ const LOCALES = {
downloading: (name) => `Baixando ${name}…`,
double_click_rename: 'Duplo clique para renomear',
symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Renomeado para ',
rename_failed: 'Falha ao renomear: ',
Expand Down Expand Up @@ -13823,6 +13832,7 @@ const LOCALES = {
downloading: (name) => `Downloading ${name}\u2026`,
double_click_rename: 'Double-click to rename',
symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Renamed to ',
rename_failed: 'Rename failed: ',
Expand Down Expand Up @@ -15298,6 +15308,7 @@ const LOCALES = {
file_open_failed: 'Impossible d\'ouvrir le fichier',
double_click_rename: 'Double-cliquez pour renommer',
symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Renommé en',
rename_failed: 'Échec du changement de nom :',
Expand Down Expand Up @@ -16839,6 +16850,7 @@ const LOCALES = {
downloading: (name) => `${name} indiriliyor\u2026`,
double_click_rename: 'Yeniden adlandırmak için çift tıklayın',
symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Yeniden adlandırıldı',
rename_failed: 'Yeniden adlandırma başarısız oldu:',
Expand Down Expand Up @@ -18389,6 +18401,7 @@ const LOCALES = {
downloading: (name) => `Pobieranie ${name}…`,
double_click_rename: 'Kliknij dwukrotnie, aby zmienić nazwę',
symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
session_rename_failed_no_row: 'Nie można rozpocząć zmiany nazwy — nie znaleziono wiersza.',
renamed_to: 'Nazwa zmieniona na ',
rename_failed: 'Zmiana nazwy nie powiodła się: ',
Expand Down
1 change: 1 addition & 0 deletions static/icons.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

46 changes: 39 additions & 7 deletions static/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -5885,7 +5885,10 @@ function showConfirmDialog(opts={}){
if(title) title.textContent=opts.title||t('dialog_confirm_title');
if(desc) desc.textContent=opts.message||'';
if(input){input.style.display='none';input.value='';}
if(cancelBtn) cancelBtn.textContent=opts.cancelLabel||t('cancel');
if(cancelBtn){
if(opts.hideCancel){cancelBtn.style.display='none';}
else{cancelBtn.style.display='';cancelBtn.textContent=opts.cancelLabel||t('cancel');}
}
if(confirmBtn){
confirmBtn.textContent=opts.confirmLabel||t('dialog_confirm_btn');
confirmBtn.classList.toggle('danger',!!opts.danger);
Expand Down Expand Up @@ -5915,7 +5918,13 @@ function showPromptDialog(opts={}){
input.value=prefill;input.placeholder=opts.placeholder||'';
input.autocomplete='off';input.spellcheck=false;
}
if(cancelBtn) cancelBtn.textContent=opts.cancelLabel||t('cancel');
if(cancelBtn){
// A prior showConfirmDialog({hideCancel:true}) (e.g. the outside-symlink info
// dialog, #4581) may have hidden the shared Cancel button; always restore it
// so a subsequent prompt keeps its Cancel affordance.
cancelBtn.style.display='';
cancelBtn.textContent=opts.cancelLabel||t('cancel');
}
if(confirmBtn){
confirmBtn.textContent=opts.confirmLabel||t('create');
confirmBtn.classList.toggle('danger',!!opts.danger);
Expand Down Expand Up @@ -14350,9 +14359,13 @@ function _renderTreeItems(container, entries, depth){
el.ondragend=()=>{el.classList.remove('dragging');_clearWorkspaceMoveDragOver();};

const isLk = item.type === 'symlink';
const isDirLike = item.type === 'dir' || (isLk && item.is_dir);
const isFileLike = !isDirLike;
const isExternalLink = isLk && item.target_outside_workspace;
// External symlinks are display-only: not expandable, not openable.
// The read gate (safe_resolve_ws) still blocks navigation through them.
const isDirLike = !isExternalLink && (item.type === 'dir' || (isLk && item.is_dir));
const isFileLike = !isExternalLink && !isDirLike;
el.dataset.wsIsDir = String(isDirLike);
if(isExternalLink){el.removeAttribute('draggable');el.ondragstart=null;}

if(isDirLike){
// Toggle arrow for directories
Expand All @@ -14373,9 +14386,11 @@ function _renderTreeItems(container, entries, depth){
// Icon
const iconEl=document.createElement('span');
iconEl.className='file-icon';
iconEl.innerHTML = isDirLike
? (isLk ? li('link', 14) : li('folder', 14))
: (isLk ? li('link', 14) : fileIcon(item.name, item.type));
iconEl.innerHTML = isExternalLink
? li('external-link', 14)
: isDirLike
? (isLk ? li('link', 14) : li('folder', 14))
: (isLk ? li('link', 14) : fileIcon(item.name, item.type));
el.appendChild(iconEl);

// Name
Expand Down Expand Up @@ -14407,6 +14422,8 @@ function _renderTreeItems(container, entries, depth){
if(_nameClickTimer){clearTimeout(_nameClickTimer);_nameClickTimer=null;}
// For directories, double-click navigates (breadcrumb view)
if(isDirLike){loadDir(item.path);return;}
// External symlinks: show informational dialog, not rename
if(isExternalLink){if(typeof el.onclick==='function')el.onclick(e);return;}
const inp=document.createElement('input');
inp.className='file-rename-input';inp.value=item.name;
inp.onclick=(e2)=>e2.stopPropagation();
Expand Down Expand Up @@ -14495,6 +14512,21 @@ function _renderTreeItems(container, entries, depth){
renderFileTree();
}
};
}else if(isExternalLink){
// Display-only: the link points outside the workspace. We do NOT disclose
// the resolved outside path (#4581 hardening) and do NOT call openFile —
// the read gate (safe_resolve_ws) blocks navigation through the link.
el.onclick=async(e)=>{
e.stopPropagation();
await showConfirmDialog({
title:item.name,
message:t('external_link_open_confirm'),
confirmLabel:t('dialog_confirm_btn'),
danger:false,
hideCancel:true,
focusCancel:false,
});
};
}else{
el.onclick=async()=>openFile(item.path);
}
Expand Down
7 changes: 4 additions & 3 deletions tests/test_1707_workspace_filename_click.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,15 +248,16 @@ def _run_node_with_clicks(click_count: int, dblclick_after_first: bool, item_typ
// Symlink locals referenced by the handler block — declared from item
// just like _renderTreeItem does before the tooltip assignment.
const isLk = item.type === 'symlink';
const isDirLike = item.type === 'dir' || (isLk && item.is_dir);
const isExternalLink = isLk && item.target_outside_workspace;
const isDirLike = !isExternalLink && (item.type === 'dir' || (isLk && item.is_dir));
const elideMiddle = (s) => s;

const runner = new Function(
'nameEl', 'el', 'item', 'S', 't', 'loadDir', 'document', 'showToast', 'api', 'window',
'setTimeout', 'clearTimeout', 'isLk', 'isDirLike', 'elideMiddle',
'setTimeout', 'clearTimeout', 'isLk', 'isExternalLink', 'isDirLike', 'elideMiddle',
'(()=>{' + handlerBlock + '})();'
);
runner(nameEl, el, item, S, t, loadDir, document, showToast, api, {}, trackedSetTimeout, trackedClearTimeout, isLk, isDirLike, elideMiddle);
runner(nameEl, el, item, S, t, loadDir, document, showToast, api, {}, trackedSetTimeout, trackedClearTimeout, isLk, isExternalLink, isDirLike, elideMiddle);

const evt = { stopPropagation: () => {} };
for (let i = 0; i < clickCount; i++) {
Expand Down
Loading
Loading