diff --git a/config/test-suites.ts b/config/test-suites.ts
index d66abd586..df19c565d 100644
--- a/config/test-suites.ts
+++ b/config/test-suites.ts
@@ -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',
diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js
index 5da8666c5..f8131dd3f 100644
--- a/src/web/public/panels-ui.js
+++ b/src/web/public/panels-ui.js
@@ -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}`);
@@ -6005,6 +6006,34 @@ 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 = `
${escapeHtml(data.error)}
`;
+ 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 += `${escapeHtml(data.error)}
`;
this.updateLogViewerStatus(windowId, 'error', 'error');
break;
@@ -6012,7 +6041,7 @@ Object.assign(CodemanApp.prototype, {
};
eventSource.onerror = () => {
- this.updateLogViewerStatus(windowId, 'disconnected', 'connection error');
+ if (!permissionDenied) this.updateLogViewerStatus(windowId, 'disconnected', 'connection error');
};
// Store reference (including drag listeners for cleanup)
diff --git a/test/log-viewer-file-approval.browser.test.ts b/test/log-viewer-file-approval.browser.test.ts
new file mode 100644
index 000000000..9ec2f26f1
--- /dev/null
+++ b/test/log-viewer-file-approval.browser.test.ts
@@ -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();
+ }
+ });
+ }
+});