feat(mobile): add filesystem path picker

This commit is contained in:
codeman-local
2026-07-25 15:28:19 +08:00
parent 86c634959d
commit 2667150f33
12 changed files with 1141 additions and 9 deletions
@@ -0,0 +1,9 @@
---
"aicodeman": minor
---
feat(mobile): browse and insert local file and folder paths
Add a root-confined filesystem picker to Link Existing and the extended mobile
keyboard bar. Selected paths remain editable at the active prompt, and a new
one-tap action clears only the current unsent input without invoking `/clear`.
+3 -1
View File
@@ -261,7 +261,9 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
### API Routes ### API Routes
~190 handlers across 20 route files in `src/web/routes/`: system (45, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, `GET /api/codex/status`, `GET /api/gemini/status`, and `GET /api/away-digest`), sessions (32, incl. `GET /api/sessions/unified`, `POST /api/sessions/:id/pin`, `PUT /api/session-order`), orchestrator (10), cases (27, incl. remote hosts CRUD + remote case-link, docker hosts CRUD + `docker-link` + `docker-quickcreate` + export/import + `docker-exports`), ralph (9), plan (8), files (14, incl. attachment register + list/history + `:attachmentId/raw`/`preview`/`thumbnail` + workspace `file-preview`/`file-thumbnail`), respawn (7), admin (8, multi-user `/api/admin/users*` incl. per-user case folders), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), cron (9, cron-style `CronJob` jobs/runs), teams (2), me (2, `/api/me` + password), search (1, `GET /api/search`), hooks (1), clipboard (1), status-telemetry (1, `POST /api/status-telemetry` ← statusLine exporter), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details. ~191 handlers across 20 route files in `src/web/routes/`: system (45, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, `GET /api/codex/status`, `GET /api/gemini/status`, and `GET /api/away-digest`), sessions (32, incl. `GET /api/sessions/unified`, `POST /api/sessions/:id/pin`, `PUT /api/session-order`), orchestrator (10), cases (27, incl. remote hosts CRUD + remote case-link, docker hosts CRUD + `docker-link` + `docker-quickcreate` + export/import + `docker-exports`), ralph (9), plan (8), files (15, incl. root-confined `GET /api/filesystem/browse`, attachment register + list/history + `:attachmentId/raw`/`preview`/`thumbnail` + workspace `file-preview`/`file-thumbnail`), respawn (7), admin (8, multi-user `/api/admin/users*` incl. per-user case folders), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), cron (9, cron-style `CronJob` jobs/runs), teams (2), me (2, `/api/me` + password), search (1, `GET /api/search`), hooks (1), clipboard (1), status-telemetry (1, `POST /api/status-telemetry` ← statusLine exporter), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
**Filesystem path picker**: Link Existing exposes a Browse button, and the extended mobile keyboard exposes `📁 Path` (insert the chosen file/folder path without Enter) plus `⌫ All` (clear only the current unsent prompt, never the agent's `/clear` command). The picker lazily lists one directory through `GET /api/filesystem/browse`, starts at the active session working directory or `/mnt/d`, hides dot entries, blocks sensitive trees and symlink escapes, and only traverses Home, `CASES_DIR`, `/mnt/d`, or extra roots explicitly configured with `CODEMAN_FILE_PICKER_ROOTS`.
**HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse<T>` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`). **HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse<T>` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`).
+26
View File
@@ -9,6 +9,7 @@
* - CleanupRegistration / CleanupResourceType — entries for the centralized CleanupManager * - CleanupRegistration / CleanupResourceType — entries for the centralized CleanupManager
* - NiceConfig / DEFAULT_NICE_CONFIG — process priority settings for `nice`/`ionice` * - NiceConfig / DEFAULT_NICE_CONFIG — process priority settings for `nice`/`ionice`
* - ProcessStats — memory/CPU/child-count snapshot for resource monitoring * - ProcessStats — memory/CPU/child-count snapshot for resource monitoring
* - FilesystemBrowseData — bounded path-picker directory listing returned to the web UI
*/ */
/** /**
@@ -68,6 +69,31 @@ export interface ProcessStats {
updatedAt: number; updatedAt: number;
} }
/** A selectable entry returned by the filesystem path-picker API. */
export interface FilesystemBrowseEntry {
name: string;
path: string;
type: 'file' | 'directory';
size?: number;
symlink?: boolean;
}
/** A named root the path picker may browse without escaping its allowlist. */
export interface FilesystemBrowseRoot {
label: string;
path: string;
}
/** Response payload for `GET /api/filesystem/browse`. */
export interface FilesystemBrowseData {
path: string;
parent: string | null;
root: string;
roots: FilesystemBrowseRoot[];
entries: FilesystemBrowseEntry[];
truncated: boolean;
}
export type CleanupResourceType = 'timer' | 'interval' | 'watcher' | 'listener' | 'stream'; export type CleanupResourceType = 'timer' | 'interval' | 'watcher' | 'listener' | 'stream';
/** /**
+5 -2
View File
@@ -1962,8 +1962,11 @@
</div> </div>
<div class="form-row"> <div class="form-row">
<label>Folder Path</label> <label>Folder Path</label>
<input type="text" id="linkCasePath" placeholder="/home/user/projects/my-project" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"> <div class="path-input-group">
<span class="form-hint">Absolute path to an existing project folder, e.g. /home/you/my-project</span> <input type="text" id="linkCasePath" placeholder="/mnt/d/AI/my-project" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
<button type="button" class="btn path-input-browse" onclick="app.openLinkCasePathPicker()">Browse&hellip;</button>
</div>
<span class="form-hint">Choose an existing folder from this computer or enter its absolute path</span>
</div> </div>
</div> </div>
<!-- Remote Tab --> <!-- Remote Tab -->
+250 -2
View File
@@ -1,7 +1,7 @@
/** /**
* @fileoverview Mobile keyboard accessory bar and modal focus trap. * @fileoverview Mobile keyboard accessory bar and modal focus trap.
* *
* Defines two exports: * Defines three exports:
* *
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual * - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
* keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, Esc, and dismiss. * keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, Esc, and dismiss.
@@ -10,12 +10,15 @@
* Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state). * Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state).
* Commands are sent as text + Enter separately for Ink compatibility. * Commands are sent as text + Enter separately for Ink compatibility.
* Only initializes on touch devices (MobileDetection.isTouchDevice guard). * Only initializes on touch devices (MobileDetection.isTouchDevice guard).
* - PathPicker (singleton object) — Lazy server-side file/folder browser shared
* by Link Existing and the extended mobile keyboard bar.
* *
* - FocusTrap (class) — Traps Tab/Shift+Tab keyboard focus within a modal element. * - FocusTrap (class) — Traps Tab/Shift+Tab keyboard focus within a modal element.
* Saves and restores previously focused element on deactivate. Used by Ralph wizard * Saves and restores previously focused element on deactivate. Used by Ralph wizard
* and other modal dialogs. * and other modal dialogs.
* *
* @globals {object} KeyboardAccessoryBar * @globals {object} KeyboardAccessoryBar
* @globals {object} PathPicker
* @globals {class} FocusTrap * @globals {class} FocusTrap
* *
* @dependency mobile-handlers.js (MobileDetection.isTouchDevice) * @dependency mobile-handlers.js (MobileDetection.isTouchDevice)
@@ -26,6 +29,227 @@
// Codeman — Keyboard accessory bar and focus trap for modals // Codeman — Keyboard accessory bar and focus trap for modals
// Loaded after mobile-handlers.js, before app.js // Loaded after mobile-handlers.js, before app.js
// ═══════════════════════════════════════════════════════════════
// Shared Filesystem Path Picker
// ═══════════════════════════════════════════════════════════════
const PathPicker = {
overlay: null,
_options: null,
_selectedPath: '',
_previousFocus: null,
_keydownHandler: null,
_loadSequence: 0,
/**
* Open the lazy filesystem browser.
* @param {{sessionId?: string, initialPath?: string, directoriesOnly?: boolean,
* title?: string, onSelect: (path: string) => void}} options
*/
open(options) {
this.close(false);
this._options = options;
this._selectedPath = '';
this._previousFocus = document.activeElement;
this._previousFocus?.blur?.();
const overlay = document.createElement('div');
overlay.className = 'path-picker-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', options.title || 'Select a path');
overlay.innerHTML = `
<div class="path-picker-dialog">
<div class="path-picker-header">
<strong class="path-picker-title"></strong>
<button type="button" class="path-picker-close" aria-label="Close">&times;</button>
</div>
<div class="path-picker-roots-row">
<label for="pathPickerRoot">Location</label>
<select id="pathPickerRoot" class="path-picker-roots"></select>
</div>
<div class="path-picker-nav">
<button type="button" class="path-picker-up" title="Parent folder" aria-label="Parent folder">&#x2191;</button>
<div class="path-picker-current" title="Current folder"></div>
<button type="button" class="path-picker-refresh" title="Refresh" aria-label="Refresh">&#x21BB;</button>
</div>
<div class="path-picker-status" aria-live="polite">Loading...</div>
<div class="path-picker-list" role="listbox"></div>
<div class="path-picker-selection">
<span class="path-picker-selection-label">Selected</span>
<span class="path-picker-selection-value">None</span>
</div>
<div class="path-picker-actions">
<button type="button" class="path-picker-current-select">Select Current Folder</button>
<span class="path-picker-action-spacer"></span>
<button type="button" class="path-picker-cancel">Cancel</button>
<button type="button" class="path-picker-confirm" disabled>Select</button>
</div>
</div>`;
this.overlay = overlay;
overlay.querySelector('.path-picker-title').textContent = options.title || 'Select a Path';
overlay.querySelector('.path-picker-close').addEventListener('click', () => this.close(true));
overlay.querySelector('.path-picker-cancel').addEventListener('click', () => this.close(true));
overlay.querySelector('.path-picker-confirm').addEventListener('click', () => this.confirm());
overlay.querySelector('.path-picker-current-select').addEventListener('click', () => {
const current = overlay.querySelector('.path-picker-current').textContent;
if (current) this.select(current);
});
overlay.querySelector('.path-picker-refresh').addEventListener('click', () => this.load());
overlay.querySelector('.path-picker-up').addEventListener('click', () => {
const parent = overlay.querySelector('.path-picker-up').dataset.parent;
if (parent) this.load(parent);
});
overlay.querySelector('.path-picker-roots').addEventListener('change', (event) => this.load(event.target.value));
overlay.addEventListener('click', (event) => {
if (event.target === overlay) this.close(true);
});
this._keydownHandler = (event) => {
if (event.key === 'Escape') {
event.preventDefault();
this.close(true);
}
};
document.addEventListener('keydown', this._keydownHandler);
document.body.appendChild(overlay);
this.load(options.initialPath || '');
},
async load(path) {
if (!this.overlay || !this._options) return;
const loadSequence = ++this._loadSequence;
const list = this.overlay.querySelector('.path-picker-list');
const status = this.overlay.querySelector('.path-picker-status');
list.replaceChildren();
status.textContent = 'Loading...';
const params = new URLSearchParams();
if (path) params.set('path', path);
if (this._options.sessionId) params.set('sessionId', this._options.sessionId);
try {
const response = await fetch(`/api/filesystem/browse?${params.toString()}`);
const result = await response.json();
if (!response.ok || !result.success) throw new Error(result.error || 'Failed to browse this folder');
if (!this.overlay || loadSequence !== this._loadSequence) return;
this.render(result.data);
} catch (error) {
if (!this.overlay || loadSequence !== this._loadSequence) return;
if (path) {
this.load('');
return;
}
status.textContent = error.message || 'Failed to browse this folder';
status.classList.add('error');
}
},
render(data) {
const rootSelect = this.overlay.querySelector('.path-picker-roots');
rootSelect.replaceChildren();
for (const root of data.roots) {
const option = document.createElement('option');
option.value = root.path;
option.textContent = `${root.label} — ${root.path}`;
option.selected = data.path === root.path || data.root === root.path;
rootSelect.appendChild(option);
}
this.overlay.querySelector('.path-picker-current').textContent = data.path;
const up = this.overlay.querySelector('.path-picker-up');
up.dataset.parent = data.parent || '';
up.disabled = !data.parent;
const status = this.overlay.querySelector('.path-picker-status');
status.classList.remove('error');
status.textContent = data.entries.length === 0
? 'This folder is empty'
: `${data.entries.length} item${data.entries.length === 1 ? '' : 's'}${data.truncated ? ' (first 500)' : ''}`;
const list = this.overlay.querySelector('.path-picker-list');
list.replaceChildren();
for (const entry of data.entries) {
const row = document.createElement('div');
row.className = 'path-picker-item';
if (entry.type === 'file' && this._options.directoriesOnly) row.classList.add('not-selectable');
row.dataset.path = entry.path;
row.dataset.type = entry.type;
row.setAttribute('role', 'option');
const open = document.createElement('button');
open.type = 'button';
open.className = 'path-picker-item-main';
const icon = document.createElement('span');
icon.className = 'path-picker-item-icon';
icon.textContent = entry.type === 'directory' ? '\uD83D\uDCC1' : '\uD83D\uDCC4';
const name = document.createElement('span');
name.className = 'path-picker-item-name';
name.textContent = entry.name;
open.append(icon, name);
if (entry.symlink) {
const link = document.createElement('span');
link.className = 'path-picker-item-link';
link.textContent = '\u2197';
open.appendChild(link);
}
if (entry.type === 'directory') {
const chevron = document.createElement('span');
chevron.className = 'path-picker-item-chevron';
chevron.textContent = '\u203A';
open.appendChild(chevron);
open.addEventListener('click', () => this.load(entry.path));
} else if (!this._options.directoriesOnly) {
open.addEventListener('click', () => this.select(entry.path));
} else {
open.disabled = true;
}
row.appendChild(open);
if (entry.type === 'directory' || !this._options.directoriesOnly) {
const choose = document.createElement('button');
choose.type = 'button';
choose.className = 'path-picker-item-select';
choose.textContent = 'Choose';
choose.addEventListener('click', () => this.select(entry.path));
row.appendChild(choose);
}
list.appendChild(row);
}
},
select(path) {
if (!this.overlay) return;
this._selectedPath = path;
this.overlay.querySelector('.path-picker-selection-value').textContent = path;
this.overlay.querySelector('.path-picker-confirm').disabled = false;
this.overlay.querySelectorAll('.path-picker-item').forEach((row) => {
const selected = row.dataset.path === path;
row.classList.toggle('selected', selected);
row.setAttribute('aria-selected', selected ? 'true' : 'false');
});
},
confirm() {
if (!this._selectedPath || !this._options) return;
const selectedPath = this._selectedPath;
const onSelect = this._options.onSelect;
this.close(false);
onSelect(selectedPath);
},
close(restoreFocus = true) {
if (this._keydownHandler) document.removeEventListener('keydown', this._keydownHandler);
this._keydownHandler = null;
this._loadSequence += 1;
this.overlay?.remove();
this.overlay = null;
const previousFocus = this._previousFocus;
this._previousFocus = null;
this._options = null;
this._selectedPath = '';
if (restoreFocus) previousFocus?.focus?.();
},
};
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// Mobile Keyboard Accessory Bar // Mobile Keyboard Accessory Bar
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
@@ -92,6 +316,8 @@ const KeyboardAccessoryBar = {
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/> <rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
</svg> </svg>
</button> </button>
<button class="accessory-btn" data-action="pick-path" title="Insert a file or folder path">&#x1F4C1; Path</button>
<button class="accessory-btn" data-action="clear-input" title="Clear the current unsent input">&#x232B; All</button>
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button> <button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
<button class="accessory-btn" data-action="shift-tab" title="Shift+Tab">⇧Tab</button> <button class="accessory-btn" data-action="shift-tab" title="Shift+Tab">⇧Tab</button>
<button class="accessory-btn" data-action="effort-max" title="/effort max">Max</button> <button class="accessory-btn" data-action="effort-max" title="/effort max">Max</button>
@@ -128,7 +354,7 @@ const KeyboardAccessoryBar = {
this.handleAction(action, btn); this.handleAction(action, btn);
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts) // Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc', 'effort-max']); const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc', 'effort-max', 'clear-input']);
if (refocusActions.has(action) || if (refocusActions.has(action) ||
((action === 'clear' || action === 'compact') && this._confirmAction)) { ((action === 'clear' || action === 'compact') && this._confirmAction)) {
if (typeof app !== 'undefined' && app.terminal) { if (typeof app !== 'undefined' && app.terminal) {
@@ -207,6 +433,12 @@ const KeyboardAccessoryBar = {
case 'paste': case 'paste':
this.pasteFromClipboard(); this.pasteFromClipboard();
break; break;
case 'pick-path':
this.pickPath();
break;
case 'clear-input':
app.clearTerminalInput?.();
break;
case 'dismiss': case 'dismiss':
// Blur active element to dismiss keyboard // Blur active element to dismiss keyboard
document.activeElement?.blur(); document.activeElement?.blur();
@@ -265,6 +497,22 @@ const KeyboardAccessoryBar = {
}).catch(() => {}); }).catch(() => {});
}, },
/** Browse the active session's workspace and insert a selected path without Enter. */
pickPath() {
if (!app.activeSessionId) return;
const session = app.sessions?.get(app.activeSessionId);
PathPicker.open({
title: 'Insert File or Folder Path',
sessionId: app.activeSessionId,
initialPath: session?.workingDir || '',
directoriesOnly: false,
onSelect: (path) => {
app.insertTerminalText?.(path);
setTimeout(() => app.terminal?.focus(), 100);
},
});
},
/** Show a paste overlay for iOS compatibility. /** Show a paste overlay for iOS compatibility.
* Handles three input paths from one dialog: * Handles three input paths from one dialog:
* - Text: long-press the textarea → Paste → Send (unchanged). * - Text: long-press the textarea → Paste → Send (unchanged).
+19
View File
@@ -1863,6 +1863,25 @@ Object.assign(CodemanApp.prototype, {
} }
}, },
openLinkCasePathPicker() {
const pathInput = document.getElementById('linkCasePath');
PathPicker.open({
title: 'Select Existing Project Folder',
initialPath: pathInput.value.trim(),
directoriesOnly: true,
onSelect: (path) => {
pathInput.value = path;
const nameInput = document.getElementById('linkCaseName');
if (!nameInput.value.trim()) {
const folderName = path.split('/').filter(Boolean).pop() || '';
if (/^[\p{L}\p{N}_-]+$/u.test(folderName)) nameInput.value = folderName;
}
pathInput.focus();
pathInput.setSelectionRange(path.length, path.length);
},
});
},
async linkRemoteCase() { async linkRemoteCase() {
const name = document.getElementById('remoteCaseName').value.trim(); const name = document.getElementById('remoteCaseName').value.trim();
const remotePath = document.getElementById('remoteCasePath').value.trim(); const remotePath = document.getElementById('remoteCasePath').value.trim();
+277
View File
@@ -10845,6 +10845,283 @@ body.touch-device.cjk-input-visible .main {
font-weight: 600; font-weight: 600;
} }
/* Shared lazy filesystem path picker (case linking + mobile input). */
.path-input-group {
display: flex;
gap: 8px;
width: 100%;
}
.path-input-group input {
flex: 1 1 auto;
min-width: 0;
}
.path-input-browse {
flex: 0 0 auto;
min-height: 38px;
}
.path-picker-overlay {
position: fixed;
inset: 0;
z-index: 10020;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.path-picker-dialog {
display: flex;
flex-direction: column;
width: min(680px, 100%);
height: min(720px, calc(100dvh - 32px));
overflow: hidden;
color: var(--text);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
}
.path-picker-header,
.path-picker-nav,
.path-picker-roots-row,
.path-picker-selection,
.path-picker-actions {
display: flex;
align-items: center;
gap: 8px;
}
.path-picker-header {
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.path-picker-title {
font-size: 0.95rem;
}
.path-picker-close {
width: 36px;
height: 36px;
color: var(--text-muted);
font-size: 1.5rem;
background: transparent;
border: 0;
border-radius: 8px;
cursor: pointer;
}
.path-picker-roots-row {
padding: 10px 12px 0;
color: var(--text-muted);
font-size: 0.75rem;
}
.path-picker-roots {
flex: 1;
min-width: 0;
padding: 8px 10px;
color: var(--text);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 8px;
}
.path-picker-nav {
padding: 10px 12px;
}
.path-picker-up,
.path-picker-refresh {
flex: 0 0 38px;
height: 38px;
color: var(--text);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
}
.path-picker-up:disabled {
opacity: 0.35;
cursor: default;
}
.path-picker-current {
flex: 1;
min-width: 0;
padding: 9px 11px;
overflow-x: auto;
color: var(--accent);
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
white-space: nowrap;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 8px;
}
.path-picker-status {
padding: 0 14px 8px;
color: var(--text-dim);
font-size: 0.7rem;
}
.path-picker-status.error {
color: var(--danger, #ef4444);
}
.path-picker-list {
flex: 1;
min-height: 140px;
overflow-y: auto;
padding: 0 10px 10px;
}
.path-picker-item {
display: flex;
align-items: stretch;
margin: 2px 0;
border: 1px solid transparent;
border-radius: 9px;
}
.path-picker-item:hover,
.path-picker-item.selected {
background: var(--bg-hover);
border-color: var(--border);
}
.path-picker-item.selected {
border-color: var(--accent);
}
.path-picker-item.not-selectable {
opacity: 0.62;
}
.path-picker-item-main {
display: flex;
flex: 1;
align-items: center;
min-width: 0;
padding: 10px 8px;
color: var(--text);
text-align: left;
background: transparent;
border: 0;
cursor: pointer;
}
.path-picker-item-name {
flex: 1;
min-width: 0;
overflow: hidden;
font-size: 0.8rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.path-picker-item-icon {
margin-right: 8px;
}
.path-picker-item-link,
.path-picker-item-chevron {
margin-left: 8px;
color: var(--text-dim);
}
.path-picker-item-select {
align-self: center;
margin-right: 6px;
padding: 6px 9px;
color: var(--accent);
background: transparent;
border: 1px solid var(--border);
border-radius: 7px;
cursor: pointer;
}
.path-picker-selection {
padding: 9px 14px;
color: var(--text-muted);
font-size: 0.72rem;
border-top: 1px solid var(--border);
}
.path-picker-selection-value {
min-width: 0;
overflow: hidden;
color: var(--text);
font-family: var(--font-mono, monospace);
text-overflow: ellipsis;
white-space: nowrap;
}
.path-picker-actions {
padding: 10px 12px 12px;
}
.path-picker-actions button {
min-height: 40px;
padding: 8px 13px;
color: var(--text);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
}
.path-picker-action-spacer {
flex: 1;
}
.path-picker-actions .path-picker-confirm {
color: #fff;
background: var(--accent);
border-color: var(--accent);
font-weight: 600;
}
.path-picker-actions .path-picker-confirm:disabled {
opacity: 0.4;
cursor: default;
}
@media (max-width: 600px) {
.path-picker-overlay {
align-items: flex-end;
padding: 0;
}
.path-picker-dialog {
width: 100%;
height: min(82dvh, 720px);
border-right: 0;
border-bottom: 0;
border-left: 0;
border-radius: 16px 16px 0 0;
padding-bottom: env(safe-area-inset-bottom, 0px);
}
.path-picker-actions {
flex-wrap: wrap;
}
.path-picker-current-select {
flex: 1 1 100%;
}
}
/* ═══════════════════════════════════════════════════════════════ /* ═══════════════════════════════════════════════════════════════
Orchestrator Panel Orchestrator Panel
═══════════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════════ */
+44
View File
@@ -2418,6 +2418,50 @@ Object.assign(CodemanApp.prototype, {
this.terminal.clear(); this.terminal.clear();
}, },
/** Insert editable text at the active prompt without pressing Enter. */
insertTerminalText(text) {
if (!this.activeSessionId || !text) return;
if (this._localEchoEnabled && this._localEchoOverlay) {
this._localEchoOverlay.appendText(text);
} else {
this.sendInput(text).catch(() => {});
}
this.terminal?.focus();
},
/**
* Clear only the current editable prompt. This is intentionally distinct
* from Ctrl+L (clear display) and the agent's destructive `/clear` command.
*/
clearTerminalInput() {
if (!this.activeSessionId) return;
if (typeof CjkInput !== 'undefined') CjkInput.clear();
if (this._inputFlushTimeout) {
clearTimeout(this._inputFlushTimeout);
this._inputFlushTimeout = null;
}
this._pendingInput = '';
if (this._localEchoEnabled && this._localEchoOverlay) {
const flushed = this._localEchoOverlay.getFlushed?.() || { count: 0, text: '' };
this._localEchoOverlay.clear();
this._localEchoOverlay.suppressBufferDetection();
this._flushedOffsets?.delete(this.activeSessionId);
this._flushedTexts?.delete(this.activeSessionId);
if (flushed.count > 0) {
this.sendInput('\x7f'.repeat(flushed.count)).catch(() => {});
}
} else {
// In non-local-echo mode the TUI already owns the editable buffer. Ctrl+U
// is the conventional kill-line key supported by shells and agent TUIs.
this.sendInput('\x15').catch(() => {});
}
this.showToast?.('Input cleared', 'success');
this.terminal?.focus();
},
/** /**
* Restore terminal size to match web UI dimensions. * Restore terminal size to match web UI dimensions.
* Use this after mobile screen attachment has squeezed the terminal. * Use this after mobile screen attachment has squeezed the terminal.
+216 -2
View File
@@ -4,9 +4,11 @@
*/ */
import { FastifyInstance, type FastifyReply } from 'fastify'; import { FastifyInstance, type FastifyReply } from 'fastify';
import { basename as pathBasename, join } from 'node:path'; import { basename as pathBasename, isAbsolute, join, relative, resolve, sep } from 'node:path';
import { createReadStream, realpathSync, type ReadStream } from 'node:fs'; import { createReadStream, realpathSync, type ReadStream } from 'node:fs';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { homedir } from 'node:os';
import type { ApiResponse, FilesystemBrowseData, FilesystemBrowseEntry, FilesystemBrowseRoot } from '../../types.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import { fileStreamManager } from '../../file-stream-manager.js'; import { fileStreamManager } from '../../file-stream-manager.js';
import { import {
@@ -22,12 +24,20 @@ import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js'; import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js'; import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js'; import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
import { canAccessOwned, findSessionOrFail, getAuthUser, validateSessionFilePath } from '../route-helpers.js'; import {
CASES_DIR,
canAccessOwned,
findSessionOrFail,
getAuthUser,
parseBody,
validateSessionFilePath,
} from '../route-helpers.js';
import type { FastifyRequest } from 'fastify'; import type { FastifyRequest } from 'fastify';
import type { SessionAttachmentHistoryItem, SessionState } from '../../types/session.js'; import type { SessionAttachmentHistoryItem, SessionState } from '../../types/session.js';
import { isSensitivePath } from '../sensitive-path.js'; import { isSensitivePath } from '../sensitive-path.js';
import { SseEvent } from '../sse-events.js'; import { SseEvent } from '../sse-events.js';
import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js'; import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js';
import { FilesystemBrowseQuerySchema } from '../schemas.js';
const MIME_TYPES: Record<string, string> = { const MIME_TYPES: Record<string, string> = {
png: 'image/png', png: 'image/png',
@@ -260,6 +270,75 @@ type AttachmentHistoryRouteItem = Omit<SessionAttachmentHistoryItem, 'externalPa
attachmentId?: string; attachmentId?: string;
}; };
const FILESYSTEM_PICKER_ENTRY_LIMIT = 500;
function isPathWithinRoot(root: string, candidate: string): boolean {
const rel = relative(root, candidate);
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
}
function isBlockedPickerPath(path: string, blockedTrees: readonly string[], directory = false): boolean {
if (isBlockedAttachmentPath(path, blockedTrees)) return true;
// The shared sensitive-path matcher describes file locations such as
// ~/.ssh/<key>. Probe a child path as well so the directory itself cannot be
// opened and used to enumerate those filenames.
return directory && isBlockedAttachmentPath(join(path, '__codeman_path_picker_probe__'), blockedTrees);
}
function configuredFilesystemPickerRoots(): Array<{ label: string; path: string }> {
const candidates: Array<{ label: string; path: string }> = [
{ label: 'Home', path: homedir() },
{ label: 'Codeman Cases', path: CASES_DIR },
{ label: 'WSL D:', path: '/mnt/d' },
];
const extraRoots = process.env.CODEMAN_FILE_PICKER_ROOTS;
if (extraRoots) {
for (const [index, path] of extraRoots
.split(',')
.map((value) => value.trim())
.filter(Boolean)
.entries()) {
candidates.push({ label: `Configured ${index + 1}`, path });
}
}
return candidates;
}
async function resolveFilesystemPickerRoots(
ctx: SessionPort & ConfigPort,
sessionId?: string
): Promise<FilesystemBrowseRoot[]> {
const candidates = configuredFilesystemPickerRoots();
if (sessionId) {
const session = ctx.sessions.get(sessionId) ?? ctx.store.getSession(sessionId);
if (!session) {
throw Object.assign(new Error(`Session ${sessionId} not found`), {
statusCode: 404,
body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`),
});
}
candidates.unshift({ label: 'Current Folder', path: session.workingDir });
}
const guard = await loadAttachmentGuardConfig();
const roots: FilesystemBrowseRoot[] = [];
const seen = new Set<string>();
for (const candidate of candidates) {
if (!isAbsolute(candidate.path)) continue;
try {
const resolved = realpathSync(candidate.path);
if (seen.has(resolved) || isBlockedPickerPath(resolved, guard.blockedTrees, true)) continue;
const stat = await fs.stat(resolved);
if (!stat.isDirectory()) continue;
seen.add(resolved);
roots.push({ label: candidate.label, path: resolved });
} catch {
// Optional roots (for example /mnt/d on non-WSL hosts) are omitted.
}
}
return roots;
}
function appendDownloadFlag(url: string): string { function appendDownloadFlag(url: string): string {
return `${url}${url.includes('?') ? '&' : '?'}download=true`; return `${url}${url.includes('?') ? '&' : '?'}download=true`;
} }
@@ -375,6 +454,141 @@ async function buildExternalAttachmentRouteItem(
} }
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort): void { export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort): void {
// Lazy filesystem listing for the Link Existing and mobile input path pickers.
app.get('/api/filesystem/browse', async (req, reply): Promise<ApiResponse<FilesystemBrowseData>> => {
const { path: requestedPath, sessionId } = parseBody(FilesystemBrowseQuerySchema, req.query);
const roots = await resolveFilesystemPickerRoots(ctx, sessionId);
if (roots.length === 0) {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No filesystem browse roots are available');
}
const fallbackRoot =
roots.find((root) => root.label === 'Current Folder') ?? roots.find((root) => root.path === '/mnt/d') ?? roots[0];
const candidatePath = resolve(requestedPath ?? fallbackRoot.path);
let resolvedPath: string;
try {
resolvedPath = realpathSync(candidatePath);
} catch {
reply.code(404);
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${candidatePath}`);
}
const matchingRoots = roots
.filter((root) => isPathWithinRoot(root.path, resolvedPath))
.sort((a, b) => b.path.length - a.path.length);
const matchingRoot = matchingRoots[0];
if (!matchingRoot) {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path is outside the allowed browse roots');
}
const guard = await loadAttachmentGuardConfig();
if (isBlockedPickerPath(resolvedPath, guard.blockedTrees, true)) {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this folder is blocked');
}
try {
const stat = await fs.stat(resolvedPath);
if (!stat.isDirectory()) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'The browse path must be a directory');
}
} catch {
reply.code(404);
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${candidatePath}`);
}
let dirEntries;
try {
dirEntries = await fs.readdir(resolvedPath, { withFileTypes: true });
} catch {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'This folder cannot be read');
}
dirEntries.sort((a, b) => {
if (a.isDirectory() && !b.isDirectory()) return -1;
if (!a.isDirectory() && b.isDirectory()) return 1;
return a.name.localeCompare(b.name);
});
const entries: FilesystemBrowseEntry[] = [];
let truncated = false;
for (const entry of dirEntries) {
if (entry.name.startsWith('.')) continue;
if (entries.length >= FILESYSTEM_PICKER_ENTRY_LIMIT) {
truncated = true;
break;
}
const visiblePath = join(candidatePath, entry.name);
let targetPath: string;
try {
targetPath = realpathSync(visiblePath);
} catch {
continue;
}
const targetRoot = roots.some((root) => isPathWithinRoot(root.path, targetPath));
if (!targetRoot) continue;
let type: FilesystemBrowseEntry['type'];
let size: number | undefined;
const symlink = entry.isSymbolicLink();
if (entry.isDirectory()) {
type = 'directory';
} else if (entry.isFile()) {
type = 'file';
} else if (symlink) {
try {
const targetStat = await fs.stat(targetPath);
type = targetStat.isDirectory() ? 'directory' : 'file';
if (type === 'file') size = targetStat.size;
} catch {
continue;
}
} else {
continue;
}
if (isBlockedPickerPath(targetPath, guard.blockedTrees, type === 'directory')) continue;
if (type === 'file' && size === undefined) {
try {
size = (await fs.stat(targetPath)).size;
} catch {
// The path is still selectable even when a size lookup races a change.
}
}
entries.push({ name: entry.name, path: visiblePath, type, size, symlink: symlink || undefined });
}
const parentCandidate = resolve(candidatePath, '..');
let parent: string | null = null;
if (candidatePath !== matchingRoot.path) {
try {
const resolvedParent = realpathSync(parentCandidate);
if (isPathWithinRoot(matchingRoot.path, resolvedParent)) parent = parentCandidate;
} catch {
// A concurrently removed parent simply disables upward navigation.
}
}
return {
success: true,
data: {
path: candidatePath,
parent,
root: matchingRoot.path,
roots,
entries,
truncated,
},
};
});
// File tree listing // File tree listing
app.get('/api/sessions/:id/files', async (req) => { app.get('/api/sessions/:id/files', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
+23
View File
@@ -49,6 +49,29 @@ const safePathSchema = z.string().max(1000).refine(isValidWorkingDir, {
message: 'Invalid path: must be absolute, no shell metacharacters or traversal', message: 'Invalid path: must be absolute, no shell metacharacters or traversal',
}); });
/**
* Filesystem picker paths are never interpolated into a shell command, so legal
* filename characters such as spaces, quotes, and parentheses are accepted.
* Containment and symlink resolution are enforced by the route after parsing.
*/
const filesystemPickerPathSchema = z
.string()
.max(4096)
.refine((p) => p.startsWith('/') && !p.includes('\0') && !p.includes('\n') && !p.includes('\r'), {
message: 'Path must be an absolute filesystem path',
})
.refine((p) => !p.split('/').includes('..'), { message: 'Path traversal is not allowed' });
/** Query validation for the lazy, allowlisted filesystem path picker. */
export const FilesystemBrowseQuerySchema = z.object({
path: filesystemPickerPathSchema.optional(),
sessionId: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id')
.optional(),
});
// ========== Env Var Allowlist ========== // ========== Env Var Allowlist ==========
/** Allowlisted env var key prefixes */ /** Allowlisted env var key prefixes */
+180
View File
@@ -0,0 +1,180 @@
/**
* @fileoverview Fast VM/static regressions for the shared filesystem picker and
* extended mobile keyboard actions. No browser or real server required.
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const keyboardSource = readFileSync(resolve('src/web/public/keyboard-accessory.js'), 'utf8');
const terminalSource = readFileSync(resolve('src/web/public/terminal-ui.js'), 'utf8');
const sessionSource = readFileSync(resolve('src/web/public/session-ui.js'), 'utf8');
const indexSource = readFileSync(resolve('src/web/public/index.html'), 'utf8');
function loadTerminalMixin() {
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, (...args: unknown[]) => unknown> };
const cjkClear = vi.fn();
const context = vm.createContext({
console,
performance,
setTimeout,
clearTimeout,
setInterval: vi.fn(),
clearInterval: vi.fn(),
requestAnimationFrame: vi.fn(),
CodemanApp: FakeCodemanApp,
CjkInput: { clear: cjkClear },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
document: { addEventListener: vi.fn() },
});
vm.runInContext(terminalSource, context, { filename: 'terminal-ui.js' });
return { mixin: FakeCodemanApp.prototype, cjkClear };
}
const terminalHarness = loadTerminalMixin();
function loadKeyboardModule() {
const app = {
activeSessionId: 'session-1',
sessions: new Map([['session-1', { workingDir: '/mnt/d/AI' }]]),
terminal: { focus: vi.fn() },
clearTerminalInput: vi.fn(),
insertTerminalText: vi.fn(),
sendInput: vi.fn(),
};
const context = vm.createContext({
app,
MobileDetection: { isTouchDevice: () => false },
URLSearchParams,
fetch: vi.fn(),
document: {},
setTimeout: (fn: () => void) => {
fn();
return 1;
},
clearTimeout: vi.fn(),
});
vm.runInContext(
`${keyboardSource}\nglobalThis.__bar = KeyboardAccessoryBar; globalThis.__picker = PathPicker;`,
context
);
return {
app,
bar: (context as unknown as { __bar: { handleAction(action: string): void } }).__bar,
picker: (context as unknown as { __picker: { open: ReturnType<typeof vi.fn> } }).__picker,
};
}
describe('mobile filesystem picker actions', () => {
it('keeps clear-input separate from the destructive /clear command', () => {
const { app, bar } = loadKeyboardModule();
bar.handleAction('clear-input');
expect(app.clearTerminalInput).toHaveBeenCalledOnce();
expect(app.sendInput).not.toHaveBeenCalled();
expect(keyboardSource).toContain('data-action="clear-input"');
expect(keyboardSource).toContain('data-action="clear" title="/clear"');
});
it('opens at the active working directory and inserts the selected path without Enter', () => {
const { app, bar, picker } = loadKeyboardModule();
picker.open = vi.fn();
bar.handleAction('pick-path');
expect(picker.open).toHaveBeenCalledOnce();
const options = picker.open.mock.calls[0][0];
expect(options).toMatchObject({
sessionId: 'session-1',
initialPath: '/mnt/d/AI',
directoriesOnly: false,
});
options.onSelect('/mnt/d/AI/project/file.ts');
expect(app.insertTerminalText).toHaveBeenCalledWith('/mnt/d/AI/project/file.ts');
expect(app.sendInput).not.toHaveBeenCalled();
});
it('wires Link Existing to the shared folder-only picker', () => {
expect(indexSource).toContain('onclick="app.openLinkCasePathPicker()"');
expect(indexSource).toContain('id="linkCasePath"');
expect(sessionSource).toContain('openLinkCasePathPicker()');
expect(sessionSource).toContain('directoriesOnly: true');
});
it('inserts a selected path into the editable local-echo prompt without sending it', () => {
const appendText = vi.fn();
const sendInput = vi.fn();
const focus = vi.fn();
const app = {
activeSessionId: 'session-1',
_localEchoEnabled: true,
_localEchoOverlay: { appendText },
terminal: { focus },
sendInput,
};
terminalHarness.mixin.insertTerminalText.call(app, '/mnt/d/AI/project');
expect(appendText).toHaveBeenCalledWith('/mnt/d/AI/project');
expect(sendInput).not.toHaveBeenCalled();
expect(focus).toHaveBeenCalledOnce();
});
it('clears pending and already-flushed prompt text without invoking /clear', () => {
const clear = vi.fn();
const suppressBufferDetection = vi.fn();
const sendInput = vi.fn(() => Promise.resolve());
const showToast = vi.fn();
const focus = vi.fn();
const app = {
activeSessionId: 'session-1',
_inputFlushTimeout: null,
_pendingInput: 'pending text',
_localEchoEnabled: true,
_localEchoOverlay: {
getFlushed: () => ({ count: 4, text: 'sent' }),
clear,
suppressBufferDetection,
},
_flushedOffsets: new Map([['session-1', 4]]),
_flushedTexts: new Map([['session-1', 'sent']]),
sendInput,
showToast,
terminal: { focus },
};
terminalHarness.mixin.clearTerminalInput.call(app);
expect(app._pendingInput).toBe('');
expect(clear).toHaveBeenCalledOnce();
expect(suppressBufferDetection).toHaveBeenCalledOnce();
expect(sendInput).toHaveBeenCalledWith('\x7f'.repeat(4));
expect(sendInput).not.toHaveBeenCalledWith('/clear');
expect(app._flushedOffsets.size).toBe(0);
expect(app._flushedTexts.size).toBe(0);
expect(showToast).toHaveBeenCalledWith('Input cleared', 'success');
expect(focus).toHaveBeenCalledOnce();
expect(terminalHarness.cjkClear).toHaveBeenCalled();
});
it('uses Ctrl+U to clear the TUI-owned prompt when local echo is disabled', () => {
const sendInput = vi.fn(() => Promise.resolve());
const app = {
activeSessionId: 'session-1',
_inputFlushTimeout: null,
_pendingInput: '',
_localEchoEnabled: false,
_localEchoOverlay: null,
sendInput,
showToast: vi.fn(),
terminal: { focus: vi.fn() },
};
terminalHarness.mixin.clearTerminalInput.call(app);
expect(sendInput).toHaveBeenCalledWith('\x15');
});
});
+89 -2
View File
@@ -8,13 +8,14 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js'; import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
import { ApiErrorCode } from '../../src/types.js';
// Mock fs/promises for file operations // Mock fs/promises for file operations
vi.mock('node:fs/promises', () => ({ vi.mock('node:fs/promises', () => ({
default: { default: {
readdir: vi.fn(async () => []), readdir: vi.fn(async () => []),
readFile: vi.fn(async () => 'file content'), readFile: vi.fn(async () => 'file content'),
stat: vi.fn(async () => ({ size: 100, isFile: () => true })), stat: vi.fn(async () => ({ size: 100, isFile: () => true, isDirectory: () => true })),
}, },
})); }));
@@ -55,13 +56,99 @@ describe('file-routes', () => {
// Default: realpathSync returns the path unchanged // Default: realpathSync returns the path unchanged
mockedRealpathSync.mockImplementation((p: string) => p as never); mockedRealpathSync.mockImplementation((p: string) => p as never);
// Default stat // Default stat
mockedStat.mockResolvedValue({ size: 100, isFile: () => true } as never); mockedStat.mockResolvedValue({ size: 100, isFile: () => true, isDirectory: () => true } as never);
}); });
afterEach(async () => { afterEach(async () => {
await harness.app.close(); await harness.app.close();
}); });
// ========== GET /api/filesystem/browse ==========
describe('GET /api/filesystem/browse', () => {
it('lists the active session folder lazily with directories first', async () => {
mockedReaddir.mockResolvedValueOnce([
{
name: 'notes.txt',
isDirectory: () => false,
isFile: () => true,
isSymbolicLink: () => false,
},
{
name: 'src',
isDirectory: () => true,
isFile: () => false,
isSymbolicLink: () => false,
},
] as never);
const path = harness.ctx._session.workingDir;
const res = await harness.app.inject({
method: 'GET',
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.path).toBe(path);
expect(body.data.roots[0]).toEqual({ label: 'Current Folder', path });
expect(body.data.entries.map((entry: { name: string; type: string }) => [entry.name, entry.type])).toEqual([
['src', 'directory'],
['notes.txt', 'file'],
]);
});
it('rejects paths outside the configured roots', async () => {
const res = await harness.app.inject({
method: 'GET',
url: `/api/filesystem/browse?path=${encodeURIComponent('/tmp/not-an-allowed-root')}`,
});
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
});
it('does not expose hidden entries or symlinks that escape the allowed roots', async () => {
const root = harness.ctx._session.workingDir;
mockedReaddir.mockResolvedValueOnce([
{
name: '.secret',
isDirectory: () => false,
isFile: () => true,
isSymbolicLink: () => false,
},
{
name: 'outside-link',
isDirectory: () => false,
isFile: () => false,
isSymbolicLink: () => true,
},
] as never);
mockedRealpathSync.mockImplementation((path: string) =>
path === `${root}/outside-link` ? ('/etc/shadow' as never) : (path as never)
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(root)}`,
});
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).data.entries).toEqual([]);
});
it('returns 404 for an unknown session scope', async () => {
const res = await harness.app.inject({
method: 'GET',
url: '/api/filesystem/browse?sessionId=missing-session',
});
expect(res.statusCode).toBe(404);
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.NOT_FOUND });
});
});
// ========== GET /api/sessions/:id/files ========== // ========== GET /api/sessions/:id/files ==========
describe('GET /api/sessions/:id/files', () => { describe('GET /api/sessions/:id/files', () => {