Compare commits

..
Author SHA1 Message Date
Codeman maintainer c50bb02e62 feat(file-viewer): show hidden files and folders
The tree endpoint has accepted `showHidden=true` since it was written; the
panel hardcoded `showHidden=false`, so dot-prefixed entries were unreachable
from the File Viewer and opening one meant guessing its path.

Adds a `.*` toggle to the panel header. It re-fetches instead of re-rendering
the cached tree (the filtering is server-side), preserves the expanded
directories so toggling does not collapse the tree, and persists per-device to
its own `codeman:fileBrowserShowHidden` key. That key is deliberately not part
of the app-settings object, which `saveAppSettings()` rebuilds from the
settings-modal DOM and would drop it on the next save.

Default is OFF, so an untouched install behaves exactly as before.

Closes #221

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 15:45:10 +02:00
14 changed files with 334 additions and 595 deletions
+23
View File
@@ -0,0 +1,23 @@
---
'aicodeman': patch
---
The File Viewer can show hidden files and folders.
`GET /api/sessions/:id/files` has always accepted `showHidden=true`, but the panel
hardcoded `showHidden=false`, so dot-prefixed entries were unreachable from the
tree: no `.gitignore`, no `.github/`, no `.env.example`, and nothing under them.
Opening one meant guessing its path.
The panel header gains a `.*` toggle. It re-fetches rather than re-rendering the
cached tree, because the filtering happens server-side, and it keeps the expanded
directories so toggling does not collapse the tree you just navigated. The state
is per-device (its own `codeman:fileBrowserShowHidden` key rather than the
app-settings object, which is rebuilt from the settings-modal DOM on save and
would drop a key toggled from outside it), defaults to OFF, and survives a reload.
Generated and version-control directories (`.git`, `node_modules`, `.next`,
`.venv`, ...) stay excluded either way: that list is about tree size, not about
hiding dotfiles.
Closes #221.
-29
View File
@@ -1,29 +0,0 @@
---
'aicodeman': patch
---
The filesystem path picker can show hidden files and folders, and the shared secret blocklist grew to make that safe.
The picker behind Link Existing's "Browse" and the mobile keyboard's `Path` key
refused every path with a dot-prefixed segment, so `.github/workflows/ci.yml`
could not be selected and a hidden folder could not even be opened. It now has
the same `.*` toggle as the File Viewer, default OFF, per-device, and it applies
to both the listing and the preview endpoint (which re-resolves the path
independently).
That filter was quietly doing security work. With every hidden path unreachable,
`isSensitivePath` never had to name the credentials that live in dot-directories,
because the picker's roots include Home. Lifting the filter removes that
accident, so the blocklist now covers them explicitly: SSH keys at any depth (not
only under `$HOME`), GPG keyrings, AWS/GCloud/Azure/Docker/Kubernetes
credentials, npm, Yarn, git, `gh`, netrc, PyPI, RubyGems, Cargo and Terraform
tokens, `.pgpass` and `.my.cnf`, and the Claude and Codeman agent credentials.
`~/.codeman/` and `~/.claude/` stay attachable as trees, since the publish skill
and the review-card loop read from them; only their secret-bearing members are
named.
Blocked trees, sensitive files, root confinement and symlink-escape checks are
all unchanged and still apply with the toggle on: a hidden entry that resolves
to a secret is dropped from the listing, and opening it is refused.
Follows #221.
+5
View File
@@ -615,6 +615,11 @@ class CodemanApp {
this.fileBrowserFilter = '';
this.fileBrowserAllExpanded = false;
this.fileBrowserDragListeners = null;
// Show hidden (dot-prefixed) files and folders in the File Viewer tree.
// Per-device, persisted to its own localStorage key by panels-ui.js. Safe to
// call a mixin method here: instantiation is deferred to DOMContentLoaded,
// so every module's Object.assign has already run.
this.fileBrowserShowHidden = this._loadFileBrowserShowHidden?.() ?? false;
this.filePreviewContent = '';
// Toast container cache (methods in panels-ui.js)
+1
View File
@@ -407,6 +407,7 @@
<div class="file-browser-header">
<span class="file-browser-title">Files</span>
<div class="file-browser-actions">
<button class="btn-icon-sm btn-file-browser-hidden" onclick="app.toggleFileBrowserHidden()" title="Show hidden files and folders" aria-label="Show hidden files and folders" aria-pressed="false" id="fileBrowserHiddenBtn">.*</button>
<button class="btn-icon-sm" onclick="app.refreshFileBrowser()" title="Refresh">&#x21BB;</button>
<button class="btn-icon-sm" onclick="app.toggleFileBrowserExpand()" title="Expand/Collapse All" id="fileBrowserExpandBtn">&#x229E;</button>
<button class="btn-icon-sm" onclick="app.closeFileBrowserPanel()" title="Close">&times;</button>
-47
View File
@@ -33,12 +33,6 @@
// Shared Filesystem Path Picker
// ═══════════════════════════════════════════════════════════════
// Per-device, and deliberately its own key rather than a shared "show hidden"
// preference with the File Viewer: that tree is confined to one workspace, while
// the picker browses Home and every configured root, so wanting dotfiles in a
// project does not imply wanting them in ~.
const PATH_PICKER_SHOW_HIDDEN_KEY = 'codeman:pathPickerShowHidden';
const PathPicker = {
overlay: null,
_options: null,
@@ -49,7 +43,6 @@ const PathPicker = {
_previewOverlay: null,
_previewRequestSequence: 0,
_previewPreviousFocus: null,
_showHidden: false,
/**
* Open the lazy filesystem browser.
@@ -60,7 +53,6 @@ const PathPicker = {
this.close(false);
this._options = options;
this._selectedPath = '';
this._showHidden = this._loadShowHidden();
this._previousFocus = document.activeElement;
this._previousFocus?.blur?.();
@@ -82,7 +74,6 @@ const PathPicker = {
<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-hidden" title="Show hidden files and folders" aria-label="Show hidden files and folders" aria-pressed="false">.*</button>
<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>
@@ -109,8 +100,6 @@ const PathPicker = {
if (current) this.select(current);
});
overlay.querySelector('.path-picker-refresh').addEventListener('click', () => this.load());
overlay.querySelector('.path-picker-hidden').addEventListener('click', () => this.toggleHidden());
this._syncHiddenButton();
overlay.querySelector('.path-picker-up').addEventListener('click', () => {
const parent = overlay.querySelector('.path-picker-up').dataset.parent;
if (parent) this.load(parent);
@@ -131,38 +120,6 @@ const PathPicker = {
this.load(options.initialPath || '');
},
_loadShowHidden() {
try {
return localStorage.getItem(PATH_PICKER_SHOW_HIDDEN_KEY) === '1';
} catch {
return false;
}
},
_syncHiddenButton() {
const btn = this.overlay?.querySelector('.path-picker-hidden');
if (!btn) return;
const label = this._showHidden ? 'Hide hidden files and folders' : 'Show hidden files and folders';
btn.classList.toggle('active', this._showHidden);
btn.setAttribute('aria-pressed', this._showHidden ? 'true' : 'false');
btn.setAttribute('title', label);
btn.setAttribute('aria-label', label);
},
toggleHidden() {
if (!this.overlay) return;
this._showHidden = !this._showHidden;
try {
localStorage.setItem(PATH_PICKER_SHOW_HIDDEN_KEY, this._showHidden ? '1' : '0');
} catch {}
this._syncHiddenButton();
// Reload where we are rather than resetting to the root. Turning the toggle
// OFF inside a hidden folder makes the current path unbrowsable again; the
// server answers 403 and load()'s catch falls back to the default root,
// which is the only place left to stand.
this.load(this.overlay.querySelector('.path-picker-current').textContent || '');
},
async load(path) {
if (!this.overlay || !this._options) return;
const loadSequence = ++this._loadSequence;
@@ -174,7 +131,6 @@ const PathPicker = {
const params = new URLSearchParams();
if (path) params.set('path', path);
if (this._options.sessionId) params.set('sessionId', this._options.sessionId);
if (this._showHidden) params.set('showHidden', 'true');
try {
const response = await fetch(`/api/filesystem/browse?${params.toString()}`);
const result = await response.json();
@@ -292,9 +248,6 @@ const PathPicker = {
const requestSequence = ++this._previewRequestSequence;
const params = new URLSearchParams({ path: entry.path });
if (this._options?.sessionId) params.set('sessionId', this._options.sessionId);
// A hidden file is only reachable while the toggle is on, and the preview
// endpoint re-resolves the path independently, so it needs the flag too.
if (this._showHidden) params.set('showHidden', 'true');
const previewUrl = `/api/filesystem/preview?${params.toString()}`;
const overlay = document.createElement('div');
+41 -2
View File
@@ -14,6 +14,7 @@
*/
const AWAY_DIGEST_LAST_VIEWED_KEY = 'codeman-away-digest-last-viewed';
const FILE_BROWSER_SHOW_HIDDEN_KEY = 'codeman:fileBrowserShowHidden';
const AWAY_DIGEST_SECTIONS = [
['needsAttention', 'Needs Attention'],
['completed', 'Completed'],
@@ -2944,18 +2945,56 @@ Object.assign(CodemanApp.prototype, {
// File Browser Panel
// ═══════════════════════════════════════════════════════════════
// Hidden files/folders (dot-prefixed) are filtered SERVER-side by
// GET /api/sessions/:id/files, so the toggle re-fetches rather than
// re-rendering the cached tree (issue #221). The flag is per-device and lives
// in its own localStorage key instead of the app-settings object: that object
// is rebuilt from the settings-modal DOM on every save, so a key toggled from
// outside the modal would be dropped the next time settings are saved.
_loadFileBrowserShowHidden() {
try {
return localStorage.getItem(FILE_BROWSER_SHOW_HIDDEN_KEY) === '1';
} catch {
return false;
}
},
_syncFileBrowserHiddenBtn() {
const btn = this.$('fileBrowserHiddenBtn');
if (!btn) return;
const on = this.fileBrowserShowHidden === true;
btn.classList.toggle('active', on);
btn.setAttribute('aria-pressed', String(on));
const label = on ? 'Hide hidden files and folders' : 'Show hidden files and folders';
btn.setAttribute('title', label);
btn.setAttribute('aria-label', label);
},
async toggleFileBrowserHidden() {
this.fileBrowserShowHidden = !this.fileBrowserShowHidden;
try {
localStorage.setItem(FILE_BROWSER_SHOW_HIDDEN_KEY, this.fileBrowserShowHidden ? '1' : '0');
} catch {}
this._syncFileBrowserHiddenBtn();
// Expanded-directory state is deliberately preserved so toggling does not
// collapse the tree the user just navigated.
if (this.activeSessionId) await this.loadFileBrowser(this.activeSessionId);
},
async loadFileBrowser(sessionId) {
if (!sessionId) return;
const treeEl = this.$('fileBrowserTree');
const statusEl = this.$('fileBrowserStatus');
this._syncFileBrowserHiddenBtn();
if (!treeEl) return;
// Show loading state
treeEl.innerHTML = '<div class="file-browser-loading">Loading files...</div>';
try {
const res = await fetch(`/api/sessions/${sessionId}/files?depth=5&showHidden=false`);
const showHidden = this.fileBrowserShowHidden === true;
const res = await fetch(`/api/sessions/${sessionId}/files?depth=5&showHidden=${showHidden}`);
if (!res.ok) throw new Error('Failed to load files');
const result = await res.json();
@@ -2967,7 +3006,7 @@ Object.assign(CodemanApp.prototype, {
// Update status
if (statusEl) {
const { totalFiles, totalDirectories, truncated } = result.data;
statusEl.textContent = `${totalFiles} files, ${totalDirectories} dirs${truncated ? ' (truncated)' : ''}`;
statusEl.textContent = `${totalFiles} files, ${totalDirectories} dirs${truncated ? ' (truncated)' : ''}${showHidden ? ' · hidden shown' : ''}`;
}
} catch (err) {
console.error('Failed to load file browser:', err);
+15 -16
View File
@@ -9192,6 +9192,20 @@ kbd {
gap: 0.25rem;
}
/* Show-hidden toggle: a literal `.*` glyph rather than an icon, so its meaning
* (dot-prefixed files and folders) survives every skin and font stack. */
.btn-file-browser-hidden {
font-family: var(--font-mono, monospace);
font-size: 0.85rem;
font-weight: 700;
letter-spacing: -0.05em;
}
.btn-file-browser-hidden.active {
color: var(--accent);
background: var(--bg-hover);
}
.file-browser-search {
padding: 0.4rem;
border-bottom: 1px solid var(--border);
@@ -11980,8 +11994,7 @@ body.touch-device.cjk-input-visible .main {
}
.path-picker-up,
.path-picker-refresh,
.path-picker-hidden {
.path-picker-refresh {
flex: 0 0 38px;
height: 38px;
color: var(--text);
@@ -11991,20 +12004,6 @@ body.touch-device.cjk-input-visible .main {
cursor: pointer;
}
/* Show-hidden toggle: a literal `.*` glyph rather than an icon, so its meaning
* (dot-prefixed files and folders) survives every skin and font stack. */
.path-picker-hidden {
font-family: var(--font-mono, monospace);
font-size: 0.9rem;
font-weight: 700;
letter-spacing: -0.05em;
}
.path-picker-hidden.active {
color: var(--accent);
border-color: var(--accent);
}
.path-picker-up:disabled {
opacity: 0.35;
cursor: default;
+8 -27
View File
@@ -315,25 +315,11 @@ function findMatchingPickerRoot(roots: FilesystemBrowseRoot[], candidate: string
.sort((a, b) => b.path.length - a.path.length)[0];
}
/**
* Whether a path has a dot-prefixed segment anywhere below its browse root.
*
* Checked against the REALPATH, so a plainly-named symlink pointing into a
* hidden tree is caught too. Callers skip it when the request opts into hidden
* entries (`showHidden`), which is why the sensitive-path blocklist and the
* blocked-tree checks must stand on their own: with the toggle on, this is no
* longer the thing keeping `~/.config/gh/hosts.yml` out of reach.
*/
function containsHiddenPickerSegment(root: string, candidate: string): boolean {
const rel = relative(root, candidate);
return rel !== '' && rel.split(sep).some((segment) => segment.startsWith('.'));
}
/** Parses the picker's opt-in `showHidden` query flag (absent means off). */
function wantsHiddenPickerEntries(showHidden?: string): boolean {
return showHidden === 'true';
}
function getFilesystemPreviewKind(fileName: string): FilesystemPreviewKind | undefined {
const extension = extname(fileName).slice(1).toLowerCase();
if (FILESYSTEM_IMAGE_PREVIEW_EXTENSIONS.has(extension)) return 'image';
@@ -445,8 +431,7 @@ async function resolveFilesystemPickerPath(
ctx: SessionPort & ConfigPort,
req: FastifyRequest,
requestedPath: string | undefined,
sessionId?: string,
showHidden = false
sessionId?: string
): Promise<ResolvedFilesystemPickerPath> {
const roots = await resolveFilesystemPickerRoots(ctx, req, sessionId);
if (roots.length === 0) {
@@ -468,7 +453,7 @@ async function resolveFilesystemPickerPath(
if (!matchingRoot) {
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Path is outside the allowed browse roots');
}
if (!showHidden && containsHiddenPickerSegment(matchingRoot.path, resolvedPath)) {
if (containsHiddenPickerSegment(matchingRoot.path, resolvedPath)) {
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Hidden paths are not available in the file picker');
}
@@ -677,14 +662,12 @@ function inheritedHeaders(reply: {
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, showHidden } = parseBody(FilesystemBrowseQuerySchema, req.query);
const includeHidden = wantsHiddenPickerEntries(showHidden);
const { path: requestedPath, sessionId } = parseBody(FilesystemBrowseQuerySchema, req.query);
const { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees } = await resolveFilesystemPickerPath(
ctx,
req,
requestedPath,
sessionId,
includeHidden
sessionId
);
if (isBlockedPickerPath(resolvedPath, blockedTrees, true)) {
@@ -720,7 +703,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
const entries: FilesystemBrowseEntry[] = [];
let truncated = false;
for (const entry of dirEntries) {
if (!includeHidden && entry.name.startsWith('.')) continue;
if (entry.name.startsWith('.')) continue;
if (entries.length >= FILESYSTEM_PICKER_ENTRY_LIMIT) {
truncated = true;
break;
@@ -735,8 +718,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
}
const targetRoot = findMatchingPickerRoot(roots, targetPath);
if (!targetRoot) continue;
if (!includeHidden && containsHiddenPickerSegment(targetRoot.path, targetPath)) continue;
if (!targetRoot || containsHiddenPickerSegment(targetRoot.path, targetPath)) continue;
let type: FilesystemBrowseEntry['type'];
let size: number | undefined;
@@ -801,13 +783,12 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
// Inline preview for files selected through the root-confined filesystem picker.
app.get('/api/filesystem/preview', { compress: false }, async (req, reply): Promise<void> => {
const { path: requestedPath, sessionId, showHidden } = parseBody(FilesystemPreviewQuerySchema, req.query);
const { path: requestedPath, sessionId } = parseBody(FilesystemPreviewQuerySchema, req.query);
const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath(
ctx,
req,
requestedPath,
sessionId,
wantsHiddenPickerEntries(showHidden)
sessionId
);
if (isBlockedPickerPath(resolvedPath, blockedTrees)) {
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked');
-10
View File
@@ -65,14 +65,6 @@ const filesystemPickerPathSchema = z
})
.refine((p) => !p.split('/').includes('..'), { message: 'Path traversal is not allowed' });
/**
* Opt-in flag for listing dot-prefixed entries in the path picker. Absent means
* off, so an old client keeps the previous behavior. It is a string rather than
* a boolean because it arrives as a query parameter; `'false'` is accepted (and
* means off) so a client can send the flag unconditionally.
*/
const showHiddenQuerySchema = z.enum(['true', 'false']).optional();
/** Query validation for the lazy, allowlisted filesystem path picker. */
export const FilesystemBrowseQuerySchema = z.object({
path: filesystemPickerPathSchema.optional(),
@@ -81,7 +73,6 @@ export const FilesystemBrowseQuerySchema = z.object({
.max(100)
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id')
.optional(),
showHidden: showHiddenQuerySchema,
});
/** Query validation for a single allowlisted path-picker file preview. */
@@ -92,7 +83,6 @@ export const FilesystemPreviewQuerySchema = z.object({
.max(100)
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id')
.optional(),
showHidden: showHiddenQuerySchema,
});
/**
+5 -55
View File
@@ -13,73 +13,23 @@
* credentials, dotenv files) while leaving ordinary cross-workspace files
* attachable.
*
* ⚠️ The path picker's `showHidden` option is what makes the dot-prefixed half
* of this list load-bearing. Before it existed, the picker refused every path
* with a hidden segment, so `~/.config/gh/hosts.yml` and friends were
* unreachable by construction and the list only had to cover the few secrets
* that live in plain sight. Opting into hidden entries removes that accident,
* so every credential location below has to be named. Adding a new browse
* surface means re-reading this file, not assuming it already covers you.
*
* ⚠️ Deliberately NOT whole-tree blocks: `~/.codeman/` (the publish skill
* attaches from it) and `~/.claude/` (transcripts and team state are ordinary
* files worth attaching). Only their secret-bearing members are named.
*
* Callers MUST resolve symlinks (realpath) BEFORE calling isSensitivePath so a
* symlink pointing at a sensitive target is also caught.
*/
import { homedir } from 'node:os';
const SENSITIVE_PATTERNS: RegExp[] = [
// System account databases.
/^\/etc\/shadow$/,
/^\/etc\/gshadow$/,
/^\/etc\/master\.passwd$/,
// SSH and GPG private key material. `.ssh/` is matched at any depth rather
// than only under homedir(): a per-project or per-deploy key directory holds
// exactly the same secret, and it drops a homedir() read that is captured at
// module load and therefore wrong for anything that changes HOME later.
/\/\.ssh\//,
/\/\.gnupg\//,
// Dotenv, in every conventional spelling (.env, .env.local, .env.production).
new RegExp(`^${homedir().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\/\\.ssh\\/`),
/\/\.env$/,
/\/\.env\./,
// Generic credential files, plus the per-vendor spellings that do not match it.
/\/credentials(\.json|\.yml|\.yaml|\.xml|\.toml|\.db)?$/i,
/\/\.aws\/(credentials|config)$/,
/\/\.aws\/sso\/cache\//,
/\/credentials(\.json|\.yml|\.yaml|\.xml)?$/i,
/\/\.aws\/credentials$/,
/\/\.gcloud\/credentials\.db$/,
/\/\.config\/gcloud\//,
/\/\.azure\//,
/\/\.docker\/config\.json$/,
/\/\.kube\/config$/,
// Package-registry and forge tokens. Each of these is a bearer credential in
// a plain-text dotfile, which is exactly what a path picker will surface.
/\/\.npmrc$/,
/\/\.yarnrc\.yml$/,
/\/\.git-credentials$/,
/\/\.config\/gh\//,
/\/\.config\/hub$/,
/\/\.netrc$/,
/\/_netrc$/,
/\/\.pypirc$/,
/\/\.gem\/credentials$/,
/\/\.cargo\/credentials(\.toml)?$/,
/\/\.terraformrc$/,
/\/\.terraform\.d\//,
// Database client credentials.
/\/\.pgpass$/,
/\/\.my\.cnf$/,
// Agent CLI credentials, including Codeman's own hook secret and user table.
// Named individually so the surrounding trees stay attachable (see above).
/\/\.claude\/\.credentials\.json$/,
/\/\.codeman[^/]*\/hook-secret$/,
/\/\.codeman[^/]*\/users\.json$/,
];
/**
+236
View File
@@ -0,0 +1,236 @@
/**
* @fileoverview File Viewer "show hidden" toggle (issue #221).
*
* Hidden (dot-prefixed) entries are filtered SERVER-side by
* `GET /api/sessions/:id/files`, which has always accepted `showHidden=true`;
* the frontend simply hardcoded `showHidden=false`. So the whole feature is the
* client honouring a persisted per-device flag, and the things that can silently
* break it are:
*
* 1. the request going out with the wrong `showHidden` value (the toggle looks
* dead: the button lights up, the tree does not change),
* 2. the toggle re-rendering the cached tree instead of re-fetching (same
* symptom, and no request in the network tab to explain it),
* 3. toggling collapsing the tree the user just navigated,
* 4. the flag not surviving a reload, or a `localStorage` throw (Safari private
* mode) taking the whole panel down with it.
*
* Loaded via `vm` with a stubbed context (no jsdom; see connection-indicator.test.ts).
* `CodemanApp`'s real constructor calls `init()`, so the prototype is exercised on
* a bare object instead of a real instance; the app.js wiring that seeds the flag
* is pinned statically at the bottom.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const panelsJs = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
const appJs = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
const indexHtml = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
const STORAGE_KEY = 'codeman:fileBrowserShowHidden';
interface FakeElement {
innerHTML: string;
textContent: string;
classes: Set<string>;
attrs: Record<string, string>;
classList: { toggle: (name: string, on: boolean) => void };
setAttribute: (name: string, value: string) => void;
}
function fakeElement(): FakeElement {
const classes = new Set<string>();
const attrs: Record<string, string> = {};
return {
innerHTML: '',
textContent: '',
classes,
attrs,
classList: {
toggle(name: string, on: boolean) {
if (on) classes.add(name);
else classes.delete(name);
},
},
setAttribute(name: string, value: string) {
attrs[name] = value;
},
};
}
/** Load panels-ui.js's mixin onto a bare object, with a stubbed DOM + storage. */
function loadPanel(store: Map<string, string> | null) {
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, unknown>;
const localStorage = {
getItem: (key: string) => {
if (!store) throw new Error('localStorage is disabled');
return store.has(key) ? store.get(key) : null;
},
setItem: (key: string, value: string) => {
if (!store) throw new Error('localStorage is disabled');
store.set(key, value);
},
removeItem: (key: string) => store?.delete(key),
};
const context = vm.createContext({
CodemanApp,
console,
localStorage,
escapeHtml: (s: string) => String(s),
document: { getElementById: () => null, addEventListener: vi.fn() },
window: { addEventListener: vi.fn() },
setTimeout,
clearTimeout,
fetch: () => {
throw new Error('fetch not stubbed');
},
});
vm.runInContext(panelsJs, context, { filename: 'panels-ui.js' });
const elements: Record<string, FakeElement> = {
fileBrowserTree: fakeElement(),
fileBrowserStatus: fakeElement(),
fileBrowserHiddenBtn: fakeElement(),
};
const requests: string[] = [];
const app = new CodemanApp() as Record<string, any>;
app.$ = (id: string) => elements[id] ?? null;
app.activeSessionId = 'sess-1';
app.fileBrowserData = null;
app.fileBrowserExpandedDirs = new Set<string>();
app.fileBrowserFilter = '';
app.fileBrowserShowHidden = app._loadFileBrowserShowHidden();
// Mirror app.js: fetch is a global in the browser, a per-app stub here.
context.fetch = async (url: string) => {
requests.push(url);
return {
ok: true,
json: async () => ({
success: true,
data: { tree: [], totalFiles: 3, totalDirectories: 1, truncated: false },
}),
};
};
return { app, elements, requests };
}
describe('File Viewer show-hidden toggle', () => {
let store: Map<string, string>;
beforeEach(() => {
store = new Map();
});
it('requests showHidden=false by default', async () => {
const { app, requests } = loadPanel(store);
expect(app.fileBrowserShowHidden).toBe(false);
await app.loadFileBrowser('sess-1');
expect(requests).toHaveLength(1);
expect(requests[0]).toContain('showHidden=false');
});
it('restores an enabled toggle from localStorage and requests showHidden=true', async () => {
store.set(STORAGE_KEY, '1');
const { app, requests } = loadPanel(store);
expect(app.fileBrowserShowHidden).toBe(true);
await app.loadFileBrowser('sess-1');
expect(requests[0]).toContain('showHidden=true');
});
it('re-fetches the tree when toggled, since hidden entries are filtered server-side', async () => {
const { app, requests } = loadPanel(store);
await app.loadFileBrowser('sess-1');
expect(requests[0]).toContain('showHidden=false');
await app.toggleFileBrowserHidden();
expect(app.fileBrowserShowHidden).toBe(true);
expect(requests).toHaveLength(2);
expect(requests[1]).toContain('showHidden=true');
expect(store.get(STORAGE_KEY)).toBe('1');
});
it('toggles back off and persists the off state', async () => {
store.set(STORAGE_KEY, '1');
const { app, requests } = loadPanel(store);
await app.toggleFileBrowserHidden();
expect(app.fileBrowserShowHidden).toBe(false);
expect(store.get(STORAGE_KEY)).toBe('0');
expect(requests[0]).toContain('showHidden=false');
});
it('keeps expanded directories across a toggle', async () => {
const { app } = loadPanel(store);
app.fileBrowserExpandedDirs.add('src');
app.fileBrowserExpandedDirs.add('src/web');
await app.toggleFileBrowserHidden();
expect([...app.fileBrowserExpandedDirs]).toEqual(['src', 'src/web']);
});
it('reflects state on the button and in the status line', async () => {
const { app, elements } = loadPanel(store);
const btn = elements.fileBrowserHiddenBtn;
await app.loadFileBrowser('sess-1');
expect(btn.classes.has('active')).toBe(false);
expect(btn.attrs['aria-pressed']).toBe('false');
expect(btn.attrs.title).toBe('Show hidden files and folders');
expect(elements.fileBrowserStatus.textContent).not.toContain('hidden shown');
await app.toggleFileBrowserHidden();
expect(btn.classes.has('active')).toBe(true);
expect(btn.attrs['aria-pressed']).toBe('true');
expect(btn.attrs.title).toBe('Hide hidden files and folders');
expect(btn.attrs['aria-label']).toBe('Hide hidden files and folders');
expect(elements.fileBrowserStatus.textContent).toContain('hidden shown');
});
it('survives a localStorage that throws (private browsing)', async () => {
const { app, requests } = loadPanel(null);
expect(app.fileBrowserShowHidden).toBe(false);
await app.toggleFileBrowserHidden();
expect(app.fileBrowserShowHidden).toBe(true);
expect(requests[0]).toContain('showHidden=true');
});
it('does not reset the preference on a panel refresh', async () => {
store.set(STORAGE_KEY, '1');
const { app, requests } = loadPanel(store);
app.refreshFileBrowser();
await Promise.resolve();
expect(app.fileBrowserShowHidden).toBe(true);
expect(requests[0]).toContain('showHidden=true');
});
});
describe('File Viewer show-hidden wiring', () => {
it('exposes the toggle in the file browser header', () => {
expect(indexHtml).toContain('onclick="app.toggleFileBrowserHidden()"');
expect(indexHtml).toContain('id="fileBrowserHiddenBtn"');
expect(indexHtml).toContain('aria-pressed="false"');
});
it('seeds the flag from storage when the app is constructed', () => {
expect(appJs).toMatch(/this\.fileBrowserShowHidden\s*=\s*this\._loadFileBrowserShowHidden\?\.\(\)/);
});
it('styles the active state so the toggle reads as on', () => {
expect(stylesCss).toContain('.btn-file-browser-hidden.active');
});
});
-187
View File
@@ -1,187 +0,0 @@
/**
* @fileoverview PathPicker "show hidden" toggle (issue #221).
*
* `PathPicker` (keyboard-accessory.js) is the shared browser behind Link
* Existing's "Browse" and the mobile keyboard's `📁 Path` key, so one toggle
* serves both. What can silently go wrong here:
*
* 1. `showHidden` missing from the browse request (toggle looks dead),
* 2. `showHidden` missing from the PREVIEW request, which re-resolves the
* path independently, so the listing would show a hidden file that then
* 403s the moment you tap it,
* 3. the toggle resetting you to the root instead of reloading where you are,
* 4. the flag not surviving a reopen, or a `localStorage` throw taking the
* picker down with it.
*
* The picker builds its dialog with innerHTML and drives it through real
* listeners, so this needs a DOM rather than a `vm` stub. It runs in the DEFAULT
* node environment and constructs a jsdom window here, matching
* markdown-sanitizer.test.ts: a per-file jsdom environment directive
* externalizes node:fs under vite and the suite then fails to load. ⚠️ Do not
* write that directive's literal name anywhere in this file, not even in prose
* like this: vitest scans the whole source for it, so merely explaining the trap
* re-arms it.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { JSDOM } from 'jsdom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const accessoryJs = readFileSync(resolve(PUBLIC, 'keyboard-accessory.js'), 'utf8');
const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
const STORAGE_KEY = 'codeman:pathPickerShowHidden';
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://localhost/' });
const jsdomWindow = dom.window as unknown as Window & typeof globalThis;
const jsdomDocument = jsdomWindow.document;
/** Evaluate keyboard-accessory.js against the jsdom window and return PathPicker. */
function loadPathPicker(fetchImpl: (url: string) => Promise<unknown>): any {
const MobileDetection = { isTouchDevice: () => false };
const factory = new Function(
'window',
'document',
'localStorage',
'fetch',
'MobileDetection',
`${accessoryJs}\nreturn PathPicker;`
);
return factory(jsdomWindow, jsdomDocument, jsdomWindow.localStorage, fetchImpl, MobileDetection);
}
function browseResponse(entries: Array<{ name: string; type: string }>, path = '/home/dev/project') {
return {
ok: true,
json: async () => ({
success: true,
data: {
path,
parent: null,
root: '/home/dev',
roots: [{ label: 'Home', path: '/home/dev' }],
entries: entries.map((e) => ({ ...e, path: `${path}/${e.name}` })),
truncated: false,
},
}),
};
}
describe('PathPicker show-hidden toggle', () => {
let PathPicker: any;
let urls: string[];
let respond: (url: string) => unknown;
beforeEach(() => {
jsdomWindow.localStorage.clear();
jsdomDocument.body.replaceChildren();
urls = [];
respond = () =>
browseResponse([
{ name: '.github', type: 'directory' },
{ name: 'src', type: 'directory' },
]);
PathPicker = loadPathPicker(async (url: string) => {
urls.push(url);
return respond(url);
});
});
afterEach(() => {
PathPicker?.close?.(false);
jsdomDocument.body.replaceChildren();
});
const open = async (options: Record<string, unknown> = {}) => {
PathPicker.open({ onSelect: () => {}, ...options });
await vi.waitFor(() => expect(urls.length).toBeGreaterThan(0));
};
const toggle = () => jsdomDocument.querySelector('.path-picker-hidden') as HTMLButtonElement;
const previewHref = () =>
(jsdomDocument.querySelector('.path-preview-open') as HTMLAnchorElement).getAttribute('href') ?? '';
it('omits showHidden by default', async () => {
await open();
expect(urls[0]).not.toContain('showHidden');
expect(toggle().getAttribute('aria-pressed')).toBe('false');
expect(toggle().classList.contains('active')).toBe(false);
expect(toggle().getAttribute('title')).toBe('Show hidden files and folders');
});
it('sends showHidden=true after the toggle is pressed, and persists it', async () => {
await open();
toggle().click();
await vi.waitFor(() => expect(urls.length).toBe(2));
expect(urls[1]).toContain('showHidden=true');
expect(jsdomWindow.localStorage.getItem(STORAGE_KEY)).toBe('1');
expect(toggle().getAttribute('aria-pressed')).toBe('true');
expect(toggle().classList.contains('active')).toBe(true);
expect(toggle().getAttribute('title')).toBe('Hide hidden files and folders');
});
it('restores the preference when the picker is reopened', async () => {
jsdomWindow.localStorage.setItem(STORAGE_KEY, '1');
await open();
expect(urls[0]).toContain('showHidden=true');
expect(toggle().getAttribute('aria-pressed')).toBe('true');
});
it('reloads the current folder rather than resetting to the root', async () => {
jsdomWindow.localStorage.setItem(STORAGE_KEY, '1');
// Sitting inside a hidden folder, reachable only because the toggle is on.
respond = () => browseResponse([{ name: 'workflows', type: 'directory' }], '/home/dev/project/.github');
await open({ initialPath: '/home/dev/project/.github' });
toggle().click();
await vi.waitFor(() => expect(urls.length).toBe(2));
expect(decodeURIComponent(urls[1])).toContain('path=/home/dev/project/.github');
expect(urls[1]).not.toContain('showHidden=true');
});
it('carries the flag into the preview request', async () => {
jsdomWindow.localStorage.setItem(STORAGE_KEY, '1');
await open();
PathPicker.openPreview({ name: '.gitignore', path: '/home/dev/project/.gitignore', previewKind: 'text' });
expect(previewHref()).toContain('showHidden=true');
});
it('leaves the preview flag off when the toggle is off', async () => {
await open();
PathPicker.openPreview({ name: 'notes.txt', path: '/home/dev/project/notes.txt', previewKind: 'text' });
expect(previewHref()).not.toContain('showHidden');
});
it('survives a localStorage that throws (private browsing)', async () => {
const storage = Object.getPrototypeOf(jsdomWindow.localStorage);
const getItem = vi.spyOn(storage, 'getItem').mockImplementation(() => {
throw new Error('denied');
});
const setItem = vi.spyOn(storage, 'setItem').mockImplementation(() => {
throw new Error('denied');
});
try {
await open();
expect(urls[0]).not.toContain('showHidden');
toggle().click();
await vi.waitFor(() => expect(urls.length).toBe(2));
expect(urls[1]).toContain('showHidden=true');
} finally {
getItem.mockRestore();
setItem.mockRestore();
}
});
it('styles the active toggle so it reads as on', () => {
expect(stylesCss).toContain('.path-picker-hidden.active');
});
});
-112
View File
@@ -167,118 +167,6 @@ describe('file-routes', () => {
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
});
// ===== showHidden=true (issue #221) =====
//
// The dotfile filter used to be doing security work by accident: with every
// hidden path unreachable, the sensitive-path blocklist never had to cover
// `~/.config/gh/hosts.yml` and friends. These pin that opting in lifts the
// hidden filter and NOTHING else — blocked trees, sensitive files and root
// confinement all still apply.
describe('showHidden=true', () => {
it('lists dot-prefixed entries', async () => {
mockedReaddir.mockResolvedValueOnce([
{ name: '.github', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
{ name: '.gitignore', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
{ name: 'src', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
] as never);
const root = harness.ctx._session.workingDir;
const res = await harness.app.inject({
method: 'GET',
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(root)}&showHidden=true`,
});
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).data.entries.map((e: { name: string }) => e.name)).toEqual([
'.github',
'src',
'.gitignore',
]);
});
it('allows navigating into a hidden descendant', async () => {
mockedReaddir.mockResolvedValueOnce([
{ name: 'workflows', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
] as never);
const hidden = `${harness.ctx._session.workingDir}/.github`;
const res = await harness.app.inject({
method: 'GET',
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(hidden)}&showHidden=true`,
});
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).data.path).toBe(hidden);
});
it('still hides dot-prefixed entries when the flag is absent or false', async () => {
const entries = [
{ name: '.gitignore', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
{ name: 'src', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
];
const root = harness.ctx._session.workingDir;
for (const query of ['', '&showHidden=false']) {
mockedReaddir.mockResolvedValueOnce(entries as never);
const res = await harness.app.inject({
method: 'GET',
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(root)}${query}`,
});
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).data.entries.map((e: { name: string }) => e.name)).toEqual(['src']);
}
});
it('rejects a showHidden value that is not a boolean string', async () => {
const res = await harness.app.inject({
method: 'GET',
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&showHidden=yes`,
});
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
});
it('still omits blocked and sensitive entries', async () => {
const root = harness.ctx._session.workingDir;
mockedReaddir.mockResolvedValueOnce([
{ name: '.ssh', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
{ name: '.npmrc', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
{ name: '.env', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
{ name: '.gitignore', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
// A plainly-named symlink whose target is a secret: caught on the
// resolved path, not the visible name.
{ name: 'notes', isDirectory: () => false, isFile: () => false, isSymbolicLink: () => true },
] as never);
mockedRealpathSync.mockImplementation((p: string) =>
p === `${root}/notes` ? (`${root}/.aws/credentials` as never) : (p as never)
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(root)}&showHidden=true`,
});
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).data.entries.map((e: { name: string }) => e.name)).toEqual(['.gitignore']);
});
it('refuses a hidden path that resolves outside every root', async () => {
const outside = `${harness.ctx._session.workingDir}/.cache`;
mockedRealpathSync.mockImplementation((p: string) =>
p === outside ? ('/tmp/somewhere-else' as never) : (p as never)
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(outside)}&showHidden=true`,
});
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
});
});
});
// ========== Multi-user scoping for the filesystem picker ==========
-110
View File
@@ -1,110 +0,0 @@
/**
* @fileoverview The shared sensitive-path blocklist (`src/web/sensitive-path.ts`).
*
* This list guards every browser-facing file surface: workspace download,
* cross-workspace attachment registration, raw/preview serving, and the
* filesystem path picker.
*
* It became load-bearing when the picker gained `showHidden` (issue #221).
* Before that, the picker refused any path with a dot-prefixed segment, so most
* of the credential locations below were unreachable by construction and the
* list only had to cover secrets that sit in plain sight. Opting into hidden
* entries removes that accident, which is why each entry is pinned here: a
* pattern silently dropped in a refactor would re-expose a real token.
*
* The list is a BLOCKLIST by design (cross-workspace attachment is a supported
* feature), so the "stays attachable" cases matter just as much: over-blocking
* breaks the publish skill and the review-card loop.
*/
import { describe, expect, it } from 'vitest';
import { isSensitivePath } from '../src/web/sensitive-path.js';
const HOME = '/home/dev';
describe('isSensitivePath', () => {
describe('blocks', () => {
const blocked: Array<[string, string]> = [
['system shadow file', '/etc/shadow'],
['system gshadow file', '/etc/gshadow'],
['BSD master password db', '/etc/master.passwd'],
['ssh keys in home', `${HOME}/.ssh/id_ed25519`],
// Not only under homedir(): a deploy key in a project is the same secret,
// and the old homedir()-anchored pattern was captured at module load.
['ssh keys anywhere', '/srv/deploy/.ssh/id_rsa'],
['gpg keyring', `${HOME}/.gnupg/private-keys-v1.d/key.key`],
['dotenv', '/srv/app/.env'],
['suffixed dotenv', '/srv/app/.env.production'],
// Pre-existing and deliberate: `.env.*` is blocked wholesale, so even a
// committed `.env.example` is refused rather than risking the one repo
// whose "example" holds a live key.
['a dotenv example', '/srv/app/.env.example'],
['generic credentials file', '/srv/app/credentials'],
['json credentials', '/srv/app/credentials.json'],
['toml credentials', '/srv/app/credentials.toml'],
['aws credentials', `${HOME}/.aws/credentials`],
['aws config', `${HOME}/.aws/config`],
['aws sso cache', `${HOME}/.aws/sso/cache/abc.json`],
['legacy gcloud credential db', `${HOME}/.gcloud/credentials.db`],
['modern gcloud config tree', `${HOME}/.config/gcloud/application_default_credentials.json`],
['azure profile', `${HOME}/.azure/accessTokens.json`],
['docker registry auth', `${HOME}/.docker/config.json`],
['kubernetes context', `${HOME}/.kube/config`],
['npm token', `${HOME}/.npmrc`],
['yarn token', `${HOME}/.yarnrc.yml`],
['git credential store', `${HOME}/.git-credentials`],
['gh cli token', `${HOME}/.config/gh/hosts.yml`],
['hub token', `${HOME}/.config/hub`],
['netrc', `${HOME}/.netrc`],
['windows netrc', `${HOME}/_netrc`],
['pypi token', `${HOME}/.pypirc`],
['rubygems token', `${HOME}/.gem/credentials`],
['cargo token', `${HOME}/.cargo/credentials.toml`],
['terraform cli config', `${HOME}/.terraformrc`],
['terraform credentials dir', `${HOME}/.terraform.d/credentials.tfrc.json`],
['postgres password file', `${HOME}/.pgpass`],
['mysql client config', `${HOME}/.my.cnf`],
['claude oauth token', `${HOME}/.claude/.credentials.json`],
['codeman hook secret', `${HOME}/.codeman/hook-secret`],
['codeman user table', `${HOME}/.codeman/users.json`],
['codeman hook secret on a named instance', `${HOME}/.codeman-beta/hook-secret`],
];
it.each(blocked)('blocks the %s', (_label, path) => {
expect(isSensitivePath(path)).toBe(true);
});
});
describe('leaves ordinary files attachable', () => {
const allowed: Array<[string, string]> = [
['a source file', '/srv/app/src/index.ts'],
['a dotfile that carries no secret', '/srv/app/.gitignore'],
['a hidden CI directory', '/srv/app/.github/workflows/ci.yml'],
// The publish skill and the review-card loop attach from these trees, so
// only their named secret members are blocked, never the whole tree.
['a codeman screenshot', `${HOME}/.codeman/screenshots/shot.png`],
['a claude transcript', `${HOME}/.claude/projects/proj/session.jsonl`],
['a claude team inbox', `${HOME}/.claude/teams/alpha/inboxes/bob.json`],
// isUnderTree-style separator awareness: a sibling name that merely starts
// with a blocked segment must not be caught.
['an unrelated sshd notes file', '/srv/notes/.sshd-setup.md'],
['a file named credentials-policy.md', '/srv/app/credentials-policy.md'],
];
it.each(allowed)('allows %s', (_label, path) => {
expect(isSensitivePath(path)).toBe(false);
});
});
it('matches on the resolved path, so callers must realpath first', () => {
// The function itself is pure string matching; this pins the contract its
// docblock states, which every caller depends on.
expect(isSensitivePath('/srv/app/looks-innocent')).toBe(false);
expect(isSensitivePath(`${HOME}/.ssh/looks-innocent`)).toBe(true);
});
});