From 1e18c336f1a0eccaf1297234d0a35900e157a428 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 21 Jun 2026 08:55:34 +0000 Subject: [PATCH] =?UTF-8?q?stage=20#4581=20onto=20master=20(v0.51.556)=20?= =?UTF-8?q?=E2=80=94=20surface=20escape-target=20symlinks=20as=20display-o?= =?UTF-8?q?nly=20workspace=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claw-io: symlinks whose target resolves outside the workspace root were silently dropped; now emitted with target_outside_workspace=True (display-only — safe_resolve_ws/open_anchored_fd still block navigation). Dedup-winner over #4573 (stronger tests). Co-authored-by: claw-io --- api/workspace.py | 11 ++- static/i18n.js | 13 +++ static/icons.js | 1 + static/ui.js | 37 ++++++-- tests/test_1707_workspace_filename_click.py | 7 +- tests/test_symlink_cycle_detection.py | 32 ++++--- tests/test_workspace_symlink_containment.py | 97 ++++++++++++++++++++- tests/test_workspace_symlink_tree_shape.py | 18 +++- 8 files changed, 188 insertions(+), 28 deletions(-) diff --git a/api/workspace.py b/api/workspace.py index 5e83d26b..c5efe174 100644 --- a/api/workspace.py +++ b/api/workspace.py @@ -1220,11 +1220,16 @@ def list_dir(workspace: Path, rel: str='.'): 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() @@ -1238,6 +1243,7 @@ def list_dir(workspace: Path, rel: str='.'): 'type': 'symlink', 'target': str(link_target), 'is_dir': is_dir, + 'target_outside_workspace': target_outside_workspace, 'mtime_ns': mtime_ns, } if not is_dir: @@ -1381,6 +1387,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() diff --git a/static/i18n.js b/static/i18n.js index 65ec0863..b79d7b01 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -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. Target: {target}', session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Renamed to ', rename_failed: 'Rename failed: ', @@ -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. Target: {target}', session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Rinominato in ', rename_failed: 'Rinomina fallita: ', @@ -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. Target: {target}', session_rename_failed_no_row: '名前変更を開始できませんでした — 行が見つかりません。', renamed_to: '名前を変更: ', rename_failed: '名前変更失敗: ', @@ -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. Target: {target}', session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Переименовано в ', rename_failed: 'Не удалось переименовать: ', @@ -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. Target: {target}', session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Renombrado a ', rename_failed: 'Error al renombrar: ', @@ -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. Target: {target}', session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Umbenannt in ', rename_failed: 'Umbenennen fehlgeschlagen: ', @@ -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. Target: {target}', session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: '已重命名为 ', rename_failed: '重命名失败:', @@ -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. Target: {target}', session_rename_failed_no_row: '無法開始重新命名,找不到資料列。', renamed_to: '已重新命名為 ', rename_failed: '重新命名失敗:', @@ -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. Target: {target}', session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Renomeado para ', rename_failed: 'Falha ao renomear: ', @@ -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. Target: {target}', session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Renamed to ', rename_failed: 'Rename failed: ', @@ -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. Target: {target}', session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Renommé en', rename_failed: 'Échec du changement de nom :', @@ -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. Target: {target}', 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:', @@ -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. Target: {target}', 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ę: ', diff --git a/static/icons.js b/static/icons.js index 5fe3a281..68e4190e 100644 --- a/static/icons.js +++ b/static/icons.js @@ -12,6 +12,7 @@ const LI_PATHS = { 'lightbulb': '', 'folder': '', 'link': '', + 'external-link': '', 'list-todo': '', // Editing / actions 'pencil': '', diff --git a/static/ui.js b/static/ui.js index 7346fed8..292c74a5 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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); @@ -14350,9 +14353,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 @@ -14373,9 +14380,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 @@ -14407,6 +14416,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(); @@ -14495,6 +14506,20 @@ function _renderTreeItems(container, entries, depth){ renderFileTree(); } }; + }else if(isExternalLink){ + // Display-only: show the resolved target, 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').replace('{target}',()=>elideMiddle(item.target||'')), + confirmLabel:t('dialog_confirm_btn'), + danger:false, + hideCancel:true, + focusCancel:false, + }); + }; }else{ el.onclick=async()=>openFile(item.path); } diff --git a/tests/test_1707_workspace_filename_click.py b/tests/test_1707_workspace_filename_click.py index b0011a9f..a5fba85a 100644 --- a/tests/test_1707_workspace_filename_click.py +++ b/tests/test_1707_workspace_filename_click.py @@ -248,15 +248,16 @@ const trackedClearTimeout = (id) => { // 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++) { diff --git a/tests/test_symlink_cycle_detection.py b/tests/test_symlink_cycle_detection.py index 2878c5c2..95fa566e 100644 --- a/tests/test_symlink_cycle_detection.py +++ b/tests/test_symlink_cycle_detection.py @@ -9,7 +9,8 @@ recursion. Covers: - Self-referencing symlink (ln -s . ~/workspace/loop) - Ancestor symlink (ln -s .. ~/workspace/up) - Internal symlink entries carry correct type / is_dir / target fields -- External symlink directories are hidden from listings and cannot be traversed +- External symlink directories are emitted as display-only rows (target_outside_workspace=True) + and cannot be traversed """ import json import os @@ -57,8 +58,8 @@ def make_session(created_list, ws=None): class TestSymlinkCycleDetection: """Symlink cycle detection in list_dir / safe_resolve_ws.""" - def test_external_symlink_filtered_from_listing(self, cleanup_test_sessions, tmp_path_factory): - """External symlink dirs should be hidden from workspace listings.""" + def test_external_symlink_emitted_as_display_only(self, cleanup_test_sessions, tmp_path_factory): + """External symlink dirs are emitted with target_outside_workspace=True (display-only).""" ws = tmp_path_factory.mktemp("ws") target = tmp_path_factory.mktemp("target") (target / "file.txt").write_text("hello") @@ -67,8 +68,11 @@ class TestSymlinkCycleDetection: sid, _ = make_session(cleanup_test_sessions, ws) listing = get(f"/api/list?session_id={sid}&path=.") - names = [e["name"] for e in listing["entries"]] - assert "ext" not in names + entries = {e["name"]: e for e in listing["entries"]} + assert "ext" in entries + assert entries["ext"]["type"] == "symlink" + assert entries["ext"]["target_outside_workspace"] is True + assert entries["ext"]["is_dir"] is True def test_internal_symlink_listed_as_symlink(self, cleanup_test_sessions, tmp_path_factory): """Internal symlink dirs should appear with type='symlink', is_dir=True.""" @@ -149,10 +153,11 @@ class TestSymlinkCycleDetection: (ws / "ext").symlink_to(target) sid, _ = make_session(cleanup_test_sessions, ws) - # List root — should hide the external symlink and not recurse. + # List root — external symlink is emitted as display-only, not traversed. listing = get(f"/api/list?session_id={sid}&path=.") - names = [e["name"] for e in listing["entries"]] - assert "ext" not in names + entries = {e["name"]: e for e in listing["entries"]} + assert "ext" in entries + assert entries["ext"]["target_outside_workspace"] is True # Traversing into ext/subdir crosses the workspace boundary and is blocked. try: @@ -177,8 +182,8 @@ class TestSymlinkCycleDetection: assert link[0]["is_dir"] is False assert link[0]["size"] == 11 # len("hello world") - def test_external_symlink_file_filtered_from_listing(self, cleanup_test_sessions, tmp_path_factory): - """External symlink files should be hidden from workspace listings.""" + def test_external_symlink_file_emitted_as_display_only(self, cleanup_test_sessions, tmp_path_factory): + """External symlink files are emitted with target_outside_workspace=True (display-only).""" ws = tmp_path_factory.mktemp("ws") real = tmp_path_factory.mktemp("real") (real / "data.txt").write_text("hello world") @@ -186,8 +191,11 @@ class TestSymlinkCycleDetection: sid, _ = make_session(cleanup_test_sessions, ws) listing = get(f"/api/list?session_id={sid}&path=.") - names = [e["name"] for e in listing["entries"]] - assert "link.txt" not in names + entries = {e["name"]: e for e in listing["entries"]} + assert "link.txt" in entries + assert entries["link.txt"]["type"] == "symlink" + assert entries["link.txt"]["target_outside_workspace"] is True + assert entries["link.txt"]["is_dir"] is False def test_path_traversal_still_blocked(self, cleanup_test_sessions, tmp_path_factory): """Raw .. traversal must still be blocked even with symlink support.""" diff --git a/tests/test_workspace_symlink_containment.py b/tests/test_workspace_symlink_containment.py index b359717e..911eda58 100644 --- a/tests/test_workspace_symlink_containment.py +++ b/tests/test_workspace_symlink_containment.py @@ -11,13 +11,19 @@ def test_safe_resolve_blocks_external_symlink_directory(tmp_path): (outside / "secret.txt").write_text("outside", encoding="utf-8") (workspace / "escape").symlink_to(outside) + # The read/list gate still blocks navigation through the escape symlink. with pytest.raises(ValueError, match="Path traversal blocked"): safe_resolve_ws(workspace, "escape") with pytest.raises(ValueError, match="Path traversal blocked"): list_dir(workspace, "escape") - assert "escape" not in {entry["name"] for entry in list_dir(workspace, ".")} + # The escape symlink is now emitted (display-only) with target_outside_workspace=True. + entries = {e["name"]: e for e in list_dir(workspace, ".")} + assert "escape" in entries + assert entries["escape"]["type"] == "symlink" + assert entries["escape"]["target_outside_workspace"] is True + assert entries["escape"]["is_dir"] is True def test_read_file_blocks_external_symlink_file(tmp_path): @@ -28,10 +34,16 @@ def test_read_file_blocks_external_symlink_file(tmp_path): (outside / "secret.txt").write_text("outside", encoding="utf-8") (workspace / "secret-link.txt").symlink_to(outside / "secret.txt") + # The read gate still blocks reading through the escape symlink. with pytest.raises(ValueError, match="Path traversal blocked"): read_file_content(workspace, "secret-link.txt") - assert "secret-link.txt" not in {entry["name"] for entry in list_dir(workspace, ".")} + # The escape symlink is now emitted (display-only) with target_outside_workspace=True. + entries = {e["name"]: e for e in list_dir(workspace, ".")} + assert "secret-link.txt" in entries + assert entries["secret-link.txt"]["type"] == "symlink" + assert entries["secret-link.txt"]["target_outside_workspace"] is True + assert entries["secret-link.txt"]["is_dir"] is False def test_internal_symlink_still_resolves_within_workspace(tmp_path): @@ -230,7 +242,9 @@ def test_list_read_create_work_on_no_dir_fd_fallback(tmp_path, monkeypatch): assert "a.txt" in names if w._DIR_FD_OK: assert "internal" in names # legit internal symlink listed - assert "escape" not in names # external symlink hidden + assert "escape" in names # external symlink emitted (display-only) + escape_entry = next(e for e in w.list_dir(workspace, ".") if e["name"] == "escape") + assert escape_entry["target_outside_workspace"] is True assert w.read_file_content(workspace, "a.txt")["content"] == "hi" fd = w.open_anchored_create_fd(workspace, workspace / "new" / "f.txt") @@ -277,3 +291,80 @@ def test_read_blocked_when_workspace_root_raced_to_symlink(tmp_path): pass # refused — correct finally: os.open = real_open + + +# ── #4510: escape-target symlinks are now emitted as display-only rows ────── +# The escape filter was widened (not removed): symlinks whose resolved target +# sits outside the workspace root are now emitted with +# target_outside_workspace=True instead of being silently dropped. The +# read/list gate (safe_resolve_ws / open_anchored_fd) is unchanged and still +# blocks navigation through them. ────────────────────────────────────────── + + +def test_list_dir_in_workspace_symlink_shape(tmp_path): + """In-workspace symlinks emit type='symlink' with target_outside_workspace=False.""" + import api.workspace as w + + if not w._DIR_FD_OK: + pytest.skip("symlink listing requires dir_fd support") + + workspace = tmp_path / "workspace" + workspace.mkdir() + (workspace / "data.txt").write_text("hello", encoding="utf-8") + (workspace / "link.txt").symlink_to(workspace / "data.txt") + + entries = {e["name"]: e for e in w.list_dir(workspace, ".")} + assert "link.txt" in entries + assert entries["link.txt"]["type"] == "symlink" + assert entries["link.txt"]["target_outside_workspace"] is False + assert entries["link.txt"]["is_dir"] is False + assert entries["link.txt"]["target"] == str((workspace / "data.txt").resolve()) + + +def test_list_dir_outside_workspace_symlink_emitted_with_flag(tmp_path): + """Escape-target symlinks are emitted with target_outside_workspace=True.""" + workspace = tmp_path / "workspace" + outside = tmp_path / "outside" + workspace.mkdir() + outside.mkdir() + (outside / "file.txt").write_text("external", encoding="utf-8") + (workspace / "ext-link.txt").symlink_to(outside / "file.txt") + + entries = {e["name"]: e for e in list_dir(workspace, ".")} + assert "ext-link.txt" in entries + assert entries["ext-link.txt"]["type"] == "symlink" + assert entries["ext-link.txt"]["target_outside_workspace"] is True + assert entries["ext-link.txt"]["is_dir"] is False + assert entries["ext-link.txt"]["target"] == str((outside / "file.txt").resolve()) + + +def test_list_dir_external_symlink_blocked_system_path_unchanged(tmp_path): + """Symlinks to blocked system paths (/etc, /usr) are still filtered out.""" + workspace = tmp_path / "workspace" + workspace.mkdir() + (workspace / "etc-link").symlink_to("/etc") + (workspace / "usr-link").symlink_to("/usr") + + names = {e["name"] for e in list_dir(workspace, ".")} + assert "etc-link" not in names + assert "usr-link" not in names + + +def test_list_dir_escape_symlink_read_still_blocked(tmp_path): + """Listing shows the escape symlink (display-only) but read_file_content + on the same target still raises ValueError — proving the read gate is intact.""" + workspace = tmp_path / "workspace" + outside = tmp_path / "outside" + workspace.mkdir() + outside.mkdir() + (outside / "secret.txt").write_text("secret", encoding="utf-8") + (workspace / "escape.txt").symlink_to(outside / "secret.txt") + + # Listing emits the entry with target_outside_workspace=True + entries = {e["name"]: e for e in list_dir(workspace, ".")} + assert "escape.txt" in entries + assert entries["escape.txt"]["target_outside_workspace"] is True + + # But reading through it is still blocked + with pytest.raises(ValueError, match="Path traversal blocked"): + read_file_content(workspace, "escape.txt") diff --git a/tests/test_workspace_symlink_tree_shape.py b/tests/test_workspace_symlink_tree_shape.py index 5ccf0b41..787c220a 100644 --- a/tests/test_workspace_symlink_tree_shape.py +++ b/tests/test_workspace_symlink_tree_shape.py @@ -27,14 +27,19 @@ class TestIsDirLikeLocals: assert "const isLk = item.type === 'symlink';" in block, \ "isLk local must be declared inside the per-item loop" + def test_isExternalLink_local_declared(self): + block = _render_block() + assert "const isExternalLink = isLk && item.target_outside_workspace;" in block, \ + "isExternalLink local must be declared for display-only external symlinks" + def test_isDirLike_local_declared(self): block = _render_block() - assert "const isDirLike = item.type === 'dir' || (isLk && item.is_dir);" in block, \ + assert "const isDirLike = !isExternalLink && (item.type === 'dir' || (isLk && item.is_dir));" in block, \ "isDirLike local must be declared inside the per-item loop" def test_isFileLike_local_declared(self): block = _render_block() - assert "const isFileLike = !isDirLike;" in block, \ + assert "const isFileLike = !isExternalLink && !isDirLike;" in block, \ "isFileLike local must be declared for file-like symlink handling" def test_wsIsDir_dataset_set(self): @@ -65,6 +70,15 @@ class TestLinkIcon: assert "li('link', 14)" in block, \ "icon dispatch must emit li('link', 14) for symlink rows" + def test_external_link_icon_path_exists(self): + assert "'external-link':" in ICONS_JS, \ + "'external-link' key must be in LI_PATHS for display-only external symlinks" + + def test_icon_dispatch_uses_li_external_link(self): + block = _render_block() + assert "li('external-link', 14)" in block, \ + "icon dispatch must emit li('external-link', 14) for external symlink rows" + class TestSymlinkTooltip: def test_symlink_link_to_key_in_i18n(self):