Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/test-suites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
* why these are a runnable suite (`npm run test:browser`) rather than skipped.
*/
export const BROWSER_TEST_GLOBS = [
'test/log-viewer-file-approval.browser.test.ts',
'test/tmux-capture-sgr.browser.test.ts',
'test/tab-rail-resize.browser.test.ts',
'test/tab-activation.browser.test.ts',
Expand Down
31 changes: 30 additions & 1 deletion src/web/public/panels-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -5979,6 +5979,7 @@ Object.assign(CodemanApp.prototype, {
CodemanBase.url(`/api/sessions/${sessionId}/tail-file?path=${encodeURIComponent(filePath)}&lines=50`)
);

let permissionDenied = false;
eventSource.onmessage = (e) => {
const data = JSON.parse(e.data);
const body = document.getElementById(`log-viewer-body-${windowId}`);
Expand All @@ -6005,14 +6006,42 @@ Object.assign(CodemanApp.prototype, {
this.updateLogViewerStatus(windowId, 'disconnected', 'ended');
break;
case 'error':
if (data.error === 'Path must be within working directory or allowed log directories') {
// This is a permanent denial, not a dropped connection. Stop the
// SSE retry loop and offer the existing one-file preview approval;
// never widen the tail stream's directory boundary.
permissionDenied = true;
eventSource.close();
body.innerHTML = `<div class="log-error">${escapeHtml(data.error)}</div>`;
this.updateLogViewerStatus(windowId, 'error', 'permission needed');
if (this.sessions.get(sessionId)?.mode === 'codex' && filePath.startsWith('/')) {
const allow = document.createElement('button');
allow.type = 'button';
allow.className = 'btn-toolbar log-viewer-allow-once';
allow.textContent = 'Allow this file once';
allow.addEventListener('click', async () => {
if (allow.disabled) return;
allow.disabled = true;
this.closeLogViewerWindow(windowId);
try {
await this.openFilePreview(filePath, sessionId);
} catch (error) {
this.showToast('Unable to preview file', 'error');
console.error('Failed to preview approved file:', error);
}
});
body.appendChild(allow);
}
break;
}
body.innerHTML += `<div class="log-error">${escapeHtml(data.error)}</div>`;
this.updateLogViewerStatus(windowId, 'error', 'error');
break;
}
};

eventSource.onerror = () => {
this.updateLogViewerStatus(windowId, 'disconnected', 'connection error');
if (!permissionDenied) this.updateLogViewerStatus(windowId, 'disconnected', 'connection error');
};

// Store reference (including drag listeners for cleanup)
Expand Down
122 changes: 122 additions & 0 deletions test/log-viewer-file-approval.browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser } from 'playwright';
import { WebServer } from '../src/web/server.js';

const id = 'originating-file-session';
const path = '/Users/example/contexts/hdai-hyperdrive/manifests/demo workloads #1%.yaml';
const denial = 'Path must be within working directory or allowed log directories';
const yaml = 'apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: demo\n';

describe('one-file approval from the blocked log viewer (#23)', () => {
let server: WebServer;
let browser: Browser;
beforeAll(async () => {
server = new WebServer(0, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
}, 60000);
afterAll(async () => {
await browser?.close();
await server?.stop();
});

for (const outcome of ['allowed', 'still blocked', 'non-codex']) {
it(`${outcome}: stops permission retries and grants only the explicitly clicked file`, async () => {
const page = await browser.newPage({ viewport: { width: 1400, height: 950 } });
const approvals: unknown[] = [],
errors: string[] = [];
let tailRequests = 0,
rawReads = 0;
page.on('pageerror', (e) => errors.push(e.message));
await page.route(`**/api/sessions/${id}/**`, async (route) => {
const url = new URL(route.request().url());
const json = (data: unknown, status = 200) =>
route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(data) });
if (url.pathname.endsWith('/tail-file')) {
tailRequests++;
expect(url.searchParams.get('path')).toBe(path);
return route.fulfill({
status: 200,
contentType: 'text/event-stream',
body: `data: ${JSON.stringify({ type: 'error', error: denial })}\n\n`,
});
}
if (url.pathname.endsWith('/attachments') && route.request().method() === 'POST') {
approvals.push(route.request().postDataJSON());
return outcome === 'still blocked'
? json({ success: false, error: 'Access to this file is blocked' }, 403)
: json({ success: true, data: { attachmentId: 'approved', fileName: path.split('/').pop() } });
}
if (url.pathname.endsWith('/attachments/approved'))
return json({
success: true,
data: {
type: 'text',
extension: 'yaml',
size: yaml.length,
mtimeMs: 1,
editable: false,
url: `/api/sessions/${id}/attachments/approved/raw`,
},
});
if (url.pathname.endsWith('/raw')) {
rawReads++;
return route.fulfill({ status: 200, contentType: 'text/plain', body: yaml });
}
return json({ success: true, data: [] });
});
try {
await page.goto(`http://127.0.0.1:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.openLogViewerWindow);
await page.waitForTimeout(3500);
await page.evaluate(
({ id, path, outcome }) => {
const app = (window as any).app;
app.sessions.set(id, {
id,
mode: outcome === 'non-codex' ? 'claude' : 'codex',
workingDir: '/Users/example/contexts/saif',
remote: { hostId: 'fixture' },
});
// The approval must remain bound to the log's session, not the active tab.
app.activeSessionId = 'different-active-session';
app.openLogViewerWindow(path, id);
},
{ id, path, outcome }
);
const viewer = page.locator('.log-viewer-window');
await expect.poll(() => viewer.innerText()).toContain(denial);
await page.waitForTimeout(3200);
expect(tailRequests).toBe(1);
expect(await viewer.locator('.status').innerText()).toBe('permission needed');
expect((await viewer.innerText()).split(denial)).toHaveLength(2);
expect(approvals).toEqual([]);
const allow = viewer.getByRole('button', { name: 'Allow this file once', exact: true });
if (outcome === 'non-codex') {
expect(await allow.count()).toBe(0);
} else {
await allow.waitFor({ state: 'visible' });
if (outcome === 'allowed') await page.screenshot({ path: '/tmp/codeman-23-blocked.png' });
await allow.click();
const body = page.locator('#filePreviewBody');
await expect
.poll(() => body.innerText())
.toContain(outcome === 'allowed' ? 'kind: ConfigMap' : 'Access to this file is blocked');
expect(approvals).toEqual([{ path, notify: false }]);
expect(await viewer.count()).toBe(0);
if (outcome === 'allowed') {
expect(rawReads).toBeGreaterThan(0);
expect(await page.locator('#filePreviewEditBtn').isVisible()).toBe(false);
await page.screenshot({ path: '/tmp/codeman-23-approved.png' });
} else {
expect(rawReads).toBe(0);
expect(await body.getByRole('button', { name: 'Allow this file once', exact: true }).count()).toBe(0);
}
}
expect(errors).toEqual([]);
} finally {
await page.close();
}
});
}
});
Loading