Compare commits

..
Author SHA1 Message Date
bincxz 5040355156 fix(ci): allow exact research helper paths in Claude runs 2026-09-07 06:29:03 +08:00
6 changed files with 106 additions and 313 deletions

No files matched your search

+2
View File
@@ -877,6 +877,7 @@ jobs:
--bare -p --permission-mode dontAsk \
--settings "$HOME/.claude/settings.json" \
--allowedTools "Read" "Bash(web-search *)" "Bash(web-fetch *)" \
"Bash($research_dir/web-search *)" "Bash($research_dir/web-fetch *)" \
--disallowedTools "WebSearch" "WebFetch" "Edit" "Write" "NotebookEdit" \
--output-format stream-json --verbose --model "$AI_MODEL" \
"$prompt"
@@ -1695,6 +1696,7 @@ jobs:
--bare -p --permission-mode dontAsk \
--settings "$HOME/.claude/settings.json" \
--allowedTools "Read" "Bash(web-search *)" "Bash(web-fetch *)" \
"Bash($research_dir/web-search *)" "Bash($research_dir/web-fetch *)" \
--disallowedTools "WebSearch" "WebFetch" "Edit" "Write" "NotebookEdit" \
--output-format stream-json --verbose --model "$AI_MODEL" \
"$prompt"
-21
View File
@@ -252,8 +252,6 @@ import {
shouldStartTerminalBackend,
} from "./terminal/restoredSessionGate";
import {
alignTerminalViewportScroll,
createSynchronizedOutputFitScheduler,
AUTO_RUN_SNIPPET_LINE_DELAY_MS,
forceSyncRenderAfterResize,
MAX_CONNECTION_LOG_DATA_CHARS,
@@ -2276,7 +2274,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
clearHibernateRetry();
clearAutoReconnect();
void cleanupSession();
synchronizedFitSchedulerRef.current?.dispose();
disposeRuntimeOnly();
};
@@ -2870,12 +2867,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
});
}, [reuseConnectionFromSessionId, sessionId, terminalBackend]);
const synchronizedFitSchedulerRef = useRef<ReturnType<typeof createSynchronizedOutputFitScheduler> | null>(null);
if (!synchronizedFitSchedulerRef.current) {
synchronizedFitSchedulerRef.current = createSynchronizedOutputFitScheduler();
}
useEffect(() => () => synchronizedFitSchedulerRef.current?.dispose(), []);
type SafeFitOptions = { force?: boolean; requireVisible?: boolean; immediate?: boolean; allowHidden?: boolean };
const pendingWriteSafeFitRef = useRef<{
term: XTerm;
@@ -2973,14 +2964,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return;
}
// Keep the frozen buffer intact until synchronized output finishes.
// Re-enter safeFit to use the latest size, buffer and reading position.
if (synchronizedFitSchedulerRef.current?.defer(term, () => {
if (termRef.current === term) {
safeFitRef.current({ ...options, force: true, immediate: true });
}
})) return;
const buffer = term.buffer.active;
const wasPinnedToBottom = buffer.viewportY >= buffer.baseY;
const savedViewportY = buffer.viewportY;
@@ -3005,10 +2988,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
forceSyncRenderAfterResize(term);
}
// Reflow changes the buffer row before xterm updates its scroll range.
// Align first so the relative restore below does not apply that delta twice.
alignTerminalViewportScroll(term);
// Preserve scroll position across resize (superset/Tabby pattern).
if (wasPinnedToBottom) {
term.scrollToBottom();
@@ -1,100 +0,0 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import type { Terminal as XTerm } from "@xterm/xterm";
import { alignTerminalViewportScroll } from "./terminalHelpers";
type ViewportSpy = {
scrollToLine: (line: number, disableSmoothScroll?: boolean) => void;
_sync?: () => void;
calls: Array<{ line: number; disableSmoothScroll?: boolean }>;
};
const createTerm = (
viewportY: number,
viewport?: ViewportSpy | null,
): XTerm => ({
buffer: { active: { viewportY } },
_core: viewport === null ? undefined : { _viewport: viewport },
}) as unknown as XTerm;
test("alignTerminalViewportScroll snaps the viewport back to the buffer row without smooth scrolling", () => {
const calls: Array<{ line: number; disableSmoothScroll?: boolean }> = [];
const viewport: ViewportSpy = {
scrollToLine: (line, disableSmoothScroll) => {
calls.push({ line, disableSmoothScroll });
},
calls,
};
const term = createTerm(247, viewport);
alignTerminalViewportScroll(term);
assert.deepEqual(calls, [{ line: 247, disableSmoothScroll: true }]);
});
test("alignTerminalViewportScroll syncs viewport dimensions before setting the position", () => {
const calls: Array<{ line: number; disableSmoothScroll?: boolean }> = [];
const order: string[] = [];
const viewport: ViewportSpy = {
_sync: () => {
order.push("sync");
},
scrollToLine: (line, disableSmoothScroll) => {
order.push("scrollToLine");
calls.push({ line, disableSmoothScroll });
},
calls,
};
const term = createTerm(247, viewport);
alignTerminalViewportScroll(term);
assert.deepEqual(order, ["sync", "scrollToLine"]);
assert.deepEqual(calls, [{ line: 247, disableSmoothScroll: true }]);
});
test("alignTerminalViewportScroll survives a failing dimension sync", () => {
const calls: Array<{ line: number; disableSmoothScroll?: boolean }> = [];
const viewport: ViewportSpy = {
_sync: () => {
throw new Error("boom");
},
scrollToLine: (line, disableSmoothScroll) => {
calls.push({ line, disableSmoothScroll });
},
calls,
};
assert.doesNotThrow(() => alignTerminalViewportScroll(createTerm(5, viewport)));
assert.deepEqual(calls, [{ line: 5, disableSmoothScroll: true }]);
});
test("alignTerminalViewportScroll is a no-op when the private viewport is unavailable", () => {
const term = createTerm(10, null);
assert.doesNotThrow(() => alignTerminalViewportScroll(term));
});
test("alignTerminalViewportScroll survives a viewport without scrollToLine", () => {
const term = {
buffer: { active: { viewportY: 5 } },
_core: { _viewport: {} },
} as unknown as XTerm;
assert.doesNotThrow(() => alignTerminalViewportScroll(term));
});
test("alignTerminalViewportScroll swallows viewport errors instead of breaking the fit", () => {
const term = {
buffer: { active: { viewportY: 5 } },
_core: {
_viewport: {
scrollToLine: () => {
throw new Error("boom");
},
},
},
} as unknown as XTerm;
assert.doesNotThrow(() => alignTerminalViewportScroll(term));
});
-81
View File
@@ -539,84 +539,3 @@ export function forceSyncRenderAfterResize(term: XTerm): void {
logger.warn("Sync render after resize failed", err);
}
}
type XTermWithPrivateViewport = XTerm & {
_core?: {
_viewport?: {
scrollToLine?: (line: number, disableSmoothScroll?: boolean) => void;
_sync?: () => void;
};
};
};
/**
* Re-align the DOM scroll position with the buffer's viewport row.
*
* xterm's reflow adjusts the buffer's viewport row (ydisp) during resize, but
* the scrollable viewport keeps its stale pixel offset. Any subsequent
* relative scroll (wheel, scrollToLine) then applies its delta twice — once
* against the buffer and once against the stale DOM offset — drifting the
* reading position (all the way to the top while shrinking, #3299). Snapping
* the viewport back to the buffer row before a relative restore removes the
* desync.
*/
export function alignTerminalViewportScroll(term: XTerm): void {
const viewport = (term as XTermWithPrivateViewport)._core?._viewport;
const scrollToLine = viewport?.scrollToLine;
if (typeof scrollToLine !== "function") return;
// After a resize, xterm only refreshes the viewport's scroll dimensions on
// its queued render callback. Setting a scroll position against the stale
// dimensions gets clamped to the old maximum while xterm records the
// requested row, so the queued sync then assumes the position was already
// applied and the DOM offset stays stale — the next wheel scroll jumps
// upward by the resize delta. Sync the dimensions now, before positioning.
// If synchronized output (DECSET 2026) is active, _sync() above is a no-op
// that merely defers DOM scroll updates until the mode ends; positioning
// here would still record the requested row as _latestYDisp against the
// stale dimensions, and the deferred sync would then see
// ydisp === _latestYDisp and skip repositioning, leaving a stale DOM
// offset. Leave positioning to that deferred sync instead: after reflow the
// buffer's ydisp differs from the recorded _latestYDisp, so it repositions
// with fresh dimensions on its own.
if (typeof viewport._sync === "function") {
try {
viewport._sync.call(viewport);
} catch (err) {
logger.warn("Sync viewport dimensions after resize failed", err);
}
}
if (term.modes?.synchronizedOutputMode) return;
try {
scrollToLine.call(viewport, term.buffer.active.viewportY, true);
} catch (err) {
logger.warn("Align viewport scroll after resize failed", err);
}
}
/** Defer the whole fit while xterm keeps the visible frame frozen (DECSET 2026). */
export function createSynchronizedOutputFitScheduler() {
let timer: ReturnType<typeof setTimeout> | undefined;
const dispose = () => {
if (timer !== undefined) clearTimeout(timer);
timer = undefined;
};
return {
dispose,
defer(term: XTerm, fit: () => void): boolean {
dispose();
if (!term.modes.synchronizedOutputMode) return false;
// Arms xterm's own synchronized-output timeout even if the program
// only enabled the mode without writing visible content afterward.
term.refresh(0, Math.max(0, term.rows - 1));
timer = setTimeout(() => {
timer = undefined;
// The caller re-enters safeFit and checks the mode again, including
// when another synchronized frame started before this retry.
fit();
}, 32);
return true;
},
};
}
@@ -0,0 +1,104 @@
/* global __dirname, process, setTimeout, clearTimeout, console */
// Opt-in real Claude Code permission check; all model traffic stays on loopback.
// Usage: node scripts/fixtures/research-permission-probe.cjs [claude executable]
const assert = require('node:assert/strict');
const http = require('node:http');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawn } = require('node:child_process');
const { prepareAiCliSettings } = require('../ai-automation.cjs');
const repository = path.resolve(__dirname, '../..');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'netcatty-research-permission-'));
const settings = path.join(root, 'settings.json');
prepareAiCliSettings({ configPath: settings, denyWeb: true, allowBrave: true, allowWrites: false });
for (const name of ['web-search', 'web-fetch', 'unrelated-helper']) {
fs.writeFileSync(path.join(root, name), '#!/bin/sh\nprintf RESEARCH_FIXTURE_OK\n', { mode: 0o755 });
}
const workflow = fs.readFileSync(path.join(repository, '.github/workflows/ai-automation.yml'), 'utf8');
const blocks = [...workflow.matchAll(/--allowedTools "Read" "Bash\(web-search \*\)"([\s\S]*?)--disallowedTools/g)];
assert.equal(blocks.length, 2, 'classification and follow-up research must both be checked');
let command;
const server = http.createServer((req, res) => {
let raw = '';
req.on('data', data => { raw += data; });
req.on('end', () => {
const body = JSON.parse(raw || '{}');
if (req.url.includes('count_tokens')) {
res.setHeader('Content-Type', 'application/json');
res.end('{"input_tokens":10}');
return;
}
const finished = (body.messages || []).some(message => Array.isArray(message.content)
&& message.content.some(item => item.type === 'tool_result'));
const block = finished ? { type: 'text', text: 'DONE' }
: { type: 'tool_use', id: 'tool_fixture', name: 'Bash', input: { command } };
const message = { id: 'msg_fixture', type: 'message', role: 'assistant', model: body.model,
content: [block], stop_reason: finished ? 'end_turn' : 'tool_use', stop_sequence: null,
usage: { input_tokens: 20, output_tokens: 10 } };
if (!body.stream) {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(message));
return;
}
res.setHeader('Content-Type', 'text/event-stream');
const emit = (type, data) => res.write(`event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`);
emit('message_start', { message: { ...message, content: [], stop_reason: null } });
emit('content_block_start', { index: 0, content_block: finished
? { type: 'text', text: '' } : { ...block, input: {} } });
emit('content_block_delta', { index: 0, delta: finished
? { type: 'text_delta', text: 'DONE' }
: { type: 'input_json_delta', partial_json: JSON.stringify(block.input) } });
emit('content_block_stop', { index: 0 });
emit('message_delta', { delta: { stop_reason: message.stop_reason, stop_sequence: null }, usage: { output_tokens: 10 } });
emit('message_stop', {});
res.end();
});
});
async function check(index, helper, expectRun, baseline = false) {
const allowed = [...blocks[index][0].split('--disallowedTools')[0].matchAll(/"([^"]+)"/g)]
.map(match => match[1].replaceAll('$research_dir', root))
.filter(rule => !baseline || !rule.includes(root));
command = `${path.join(root, helper)} smoke 2>&1 | head -40`;
const args = ['--bare', '-p', '--permission-mode', 'dontAsk', '--settings', settings,
'--allowedTools', ...allowed, '--disallowedTools', 'WebSearch', 'WebFetch', 'Edit', 'Write', 'NotebookEdit',
'--output-format', 'stream-json', '--verbose', '--model', 'claude-sonnet-4-6', 'Run the approved helper once.'];
const env = { PATH: `${root}${path.delimiter}${process.env.PATH}`, HOME: process.env.HOME,
TMPDIR: process.env.TMPDIR, CLAUDE_CONFIG_DIR: path.join(root, 'config'),
ANTHROPIC_BASE_URL: `http://127.0.0.1:${server.address().port}`, ANTHROPIC_AUTH_TOKEN: 'synthetic-local-fixture',
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' };
let output = '', errors = '';
const code = await new Promise((resolve, reject) => {
const child = spawn(process.argv[2] || 'claude', args, { cwd: root, env, stdio: ['ignore', 'pipe', 'pipe'] });
const timeout = setTimeout(() => child.kill('SIGTERM'), 20000);
child.stdout.on('data', data => { output += data; });
child.stderr.on('data', data => { errors += data; });
child.once('error', error => { clearTimeout(timeout); reject(error); });
child.once('exit', value => { clearTimeout(timeout); resolve(value); });
});
assert.equal(code, 0, errors);
const events = output.trim().split('\n').map(line => JSON.parse(line));
const result = events.findLast(event => event.type === 'result');
assert.ok(result, 'Claude must finish the model/tool turn');
const ran = events.some(event => Array.isArray(event.message?.content)
&& event.message.content.some(item => item.type === 'tool_result'
&& JSON.stringify(item.content).includes('RESEARCH_FIXTURE_OK') && !item.is_error));
assert.equal(ran, expectRun, `${index}:${helper}:baseline=${baseline}`);
assert.equal((result.permission_denials || []).length, expectRun ? 0 : 1);
console.log(JSON.stringify({ route: index === 0 ? 'classification' : 'follow-up', helper, baseline, ran }));
}
(async () => {
try {
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
for (const index of [0, 1]) {
await check(index, 'web-search', false, true);
await check(index, 'web-search', true);
await check(index, 'web-fetch', true);
await check(index, 'unrelated-helper', false);
}
} finally {
server.closeAllConnections();
await new Promise(resolve => server.close(resolve));
fs.rmSync(root, { recursive: true, force: true });
}
})().catch(error => { console.error(error.message); process.exitCode = 1; });
-111
View File
@@ -1,111 +0,0 @@
"use strict";
/* global process, __dirname, console */
if (!process.versions.electron) {
require("node:test")("fit preserves terminal reading position", {
skip: "run with Electron for real xterm viewport coverage",
}, () => {});
} else {
const { app, BrowserWindow } = require("electron");
const fs = require("node:fs");
const path = require("node:path");
const esbuild = require("esbuild");
const temp = require("../electron/bridges/tempDirBridge.cjs");
const root = path.resolve(__dirname, "..");
const userData = fs.mkdtempSync(`${temp.getTempFilePath("fit-scroll")}-`);
app.setPath("userData", userData);
app.on("window-all-closed", () => {});
const source = fs.readFileSync(path.join(root, "components/Terminal.tsx"), "utf8");
const start = source.indexOf(" const safeFit = (options?: SafeFitOptions) => {");
const end = source.indexOf(" const prevIsResizingRef", start);
if (start < 0 || end < 0) throw new Error("Cannot extract production safeFit");
const safeFitSource = esbuild.transformSync(source.slice(start, end), { loader: "ts" }).code;
const bundle = esbuild.buildSync({
stdin: {
contents: 'export {Terminal} from "@xterm/xterm"; export {alignTerminalViewportScroll, forceSyncRenderAfterResize, createSynchronizedOutputFitScheduler} from "./components/terminal/terminalHelpers";',
loader: "ts", resolveDir: root,
},
bundle: true, format: "cjs", platform: "browser", write: false,
define: { "import.meta.env.DEV": "false", "import.meta.env.PROD": "true", "import.meta": "{}" },
}).outputFiles[0].text;
let win;
const cleanup = code => {
win?.destroy();
fs.rmSync(userData, { recursive: true, force: true });
app.exit(code);
};
void app.whenReady().then(async () => {
win = new BrowserWindow({
show: true, width: 1000, height: 700,
webPreferences: { nodeIntegration: true, contextIsolation: false, sandbox: false, backgroundThrottling: false },
});
await win.loadURL("data:text/html,<body style='background:%23111;color:white'><h3>Issue 3299: resize reading position</h3><div id='terminal'></div>");
await win.webContents.insertCSS(fs.readFileSync(require.resolve("@xterm/xterm/css/xterm.css"), "utf8"));
const result = await win.webContents.executeJavaScript(`(async () => {
const assert = require('node:assert/strict');
const loaded = {exports:{}};
((module,exports)=>{${bundle}})(loaded,loaded.exports);
const {Terminal,forceSyncRenderAfterResize,createSynchronizedOutputFitScheduler} = loaded.exports;
const alignTerminalViewportScroll = process.env.NETCATTY_FIT_BASELINE ? ()=>{} : loaded.exports.alignTerminalViewportScroll;
const wait = ms => new Promise(resolve=>setTimeout(resolve,ms));
const term = new Terminal({cols:80,rows:25,scrollback:1000,fontSize:14,allowProposedApi:true,smoothScrollDuration:0});
const el = document.getElementById('terminal');
el.style.cssText='width:900px;height:560px'; term.open(el);
const write = data => new Promise(resolve=>term.write(data,resolve));
const ref = current => ({current});
const termRef=ref(term), containerRef=ref(el), isRendererActiveRef=ref(true), lastFittedSizeRef=ref(null);
const autocompleteRepositionRef=ref(null), xtermRuntimeRef=ref(null), pendingWriteSafeFitRef=ref(null);
const synchronizedFitSchedulerRef=ref(createSynchronizedOutputFitScheduler());
let dimensions={cols:80,rows:25};
const fitAddonRef=ref({proposeDimensions:()=>dimensions});
const hasPendingTerminalWrites=()=>false;
const XTERM_PERFORMANCE_CONFIG={resize:{useRAF:false}};
const logger={warn:(message,error)=>{throw error || new Error(message)}};
${safeFitSource}
const safeFitRef=ref(safeFit);
const fit=(cols,rows)=>{dimensions={cols,rows};safeFit({force:true,immediate:true});};
const domRow=()=>term._core._viewport._scrollableElement.getScrollPosition().scrollTop / term._core._renderService.dimensions.css.cell.height;
const check=(expected,label)=>{
assert.equal(term.buffer.active.viewportY,expected,label+' buffer');
assert.ok(Math.abs(domRow()-expected)<0.01,label+' DOM expected '+expected+' actual '+domRow());
};
await write(Array.from({length:200},(_,i)=>'line '+i+' '+('A B repeated '.repeat(7))).join('\\r\\n'));
await wait(100); term.scrollToLine(70); await wait(50);
fit(80,16); await wait(80); check(70,'shrink');
fit(80,30); await wait(80); check(70,'grow');
for(let rows=29;rows>=12;rows--){fit(80,rows);await wait(20);check(70,'drag '+rows);}
term.scrollToBottom();await wait(50);fit(80,8);await wait(80);check(term.buffer.active.baseY,'pinned shrink');
term.scrollLines(-1);await wait(50);check(term.buffer.active.baseY-1,'first scroll after shrink');
term.scrollToLine(70);await wait(50);
const oldSize=[term.cols,term.rows];
await write('\\x1b[?2026h');fit(100,20);fit(120,24);await wait(80);
assert.deepEqual([term.cols,term.rows],oldSize,'no reflow inside synchronized frame');
term.scrollLines(-3);await wait(40);const selected=term.buffer.active.viewportY;
await write('\\x1b[?2026l');await wait(120);
assert.deepEqual([term.cols,term.rows],[120,24]);check(Math.min(selected,term.buffer.active.baseY),'user selected position');
await write('\\x1b[?2026h');fit(90,18);await write('\\x1b[?2026l\\x1b[?2026h');await wait(70);
assert.equal(term.cols,120,'a new synchronized frame delays retry');
await write('\\x1b[?2026l');await wait(100);check(Math.min(selected,term.buffer.active.baseY),'mode reentry');
await write('\\r\\n'+Array.from({length:1200},()=> 'A B repeated').join('\\r\\n'));
await wait(80);term.scrollToLine(100);await wait(30);
await write('\\x1b[?2026h');fit(130,22);await write('\\r\\n'.repeat(5));
const afterTrim=term.buffer.active.viewportY;
await write('\\x1b[?2026l');await wait(100);check(afterTrim,'scrollback trim');
await write('\\x1b[?2026h\\x1b[?1049h');fit(100,20);
await write('alternate output\\x1b[?1049l\\x1b[?2026l');await wait(100);
check(Math.min(afterTrim,term.buffer.active.baseY),'alternate buffer round trip');
await write('\\x1b[?2026h');fit(110,21);await wait(1400);
assert.equal(term.modes.synchronizedOutputMode,false,'xterm timeout releases output');
assert.equal(term.cols,110,'fit retried after output timeout');
const beforeDispose=term.cols;
await write('\\x1b[?2026h');fit(140,25);synchronizedFitSchedulerRef.current.dispose();
await write('\\x1b[?2026l');await wait(100);assert.equal(term.cols,beforeDispose,'teardown cancels retry');
assert.ok(el.querySelector('.xterm-screen').getBoundingClientRect().height > 0,'real rendered terminal');
return {passed:['shrink','grow','18 drag steps','bottom and next scroll','synchronized repeated spaced text','user scroll','mode reentry','full scrollback trim','alternate buffer','output timeout','cleanup'],viewportY:term.buffer.active.viewportY,domRow:domRow()};
})()`);
console.log(JSON.stringify(result));
if (process.env.NETCATTY_FIT_SCREENSHOT) {
fs.writeFileSync(process.env.NETCATTY_FIT_SCREENSHOT, (await win.webContents.capturePage()).toPNG());
}
cleanup(0);
}).catch(error=>{console.error(error);cleanup(1);});
}