mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-31 10:52:38 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e77df6f063 | ||
|
|
158f204baa | ||
|
|
1c34384651 | ||
|
|
3e7d871a33 | ||
|
|
758966a79c | ||
|
|
f0458dd7cc | ||
|
|
52797e9edd |
No files matched your search
@@ -129,7 +129,6 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
|
||||
- **Session management** — run multiple connections side-by-side
|
||||
- **Save current screen** — right-click a terminal to save the visible final text, without replaying keystrokes or overwritten output; scroll first to save an earlier screen.
|
||||
- **Inline images** — render Kitty graphics, SIXEL and iTerm inline images from remote programs
|
||||
- **Archive shortcuts** — Ctrl+click a printed tar/zip filename at an empty Linux shell prompt to prepare an extraction command; press Enter to run it. Relative filenames are available only in the latest command's output; run `ls` again after submitting another command. Existing input is kept, and shortcuts stay off for unknown systems and broadcast mode. Names with spaces must be printed in quotes; wrapped filenames are excluded.
|
||||
|
||||
### 📁 SFTP + Built-in Editor
|
||||
- **File workflows** — drag & drop uploads/downloads
|
||||
|
||||
@@ -757,7 +757,10 @@ export function useAIState() {
|
||||
// Non-destructive "undo last turn": branch the session at the boundary
|
||||
// before the latest user message and keep the original in history intact.
|
||||
// Returns the new branch id plus the undone user message for the composer.
|
||||
const undoLastTurnInSession = useCallback(async (sessionId: string): Promise<{
|
||||
const undoLastTurnInSession = useCallback(async (
|
||||
sessionId: string,
|
||||
viewingScope?: Pick<AISessionScope, 'type' | 'targetId'>,
|
||||
): Promise<{
|
||||
sessionId: string;
|
||||
restored: UndoLastTurnRestoredDraft;
|
||||
} | null> => {
|
||||
@@ -768,6 +771,11 @@ export function useAIState() {
|
||||
if (!result) return null;
|
||||
|
||||
const branched = result.session;
|
||||
// Like Fork, Undo belongs to the panel viewing the source, which may have
|
||||
// resumed a chat from a closed terminal or inherited it after a merge.
|
||||
if (viewingScope) {
|
||||
branched.scope = { ...branched.scope, ...viewingScope };
|
||||
}
|
||||
setSessionsRaw(prev => {
|
||||
// Keep both recently used chats within the existing history limit.
|
||||
const next = [branched, { ...source, updatedAt: branched.updatedAt }, ...prev.filter(session => session.id !== source.id)];
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { AISession, UploadedFile } from '../../infrastructure/ai/types.ts';
|
||||
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime.ts';
|
||||
import { useAIState } from './useAIState.ts';
|
||||
import { useAISessionsStore } from './aiSessionsStore.ts';
|
||||
import { getSessionScopeMatchRank } from '../../components/ai/sessionScopeMatch.ts';
|
||||
import { setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
|
||||
|
||||
const SCOPE_KEY = 'terminal:terminal-a';
|
||||
@@ -192,3 +193,39 @@ for (const newerCount of [49, 50]) {
|
||||
reloaded.container.remove(); reloaded.dom.window.close();
|
||||
});
|
||||
}
|
||||
|
||||
for (const viewingScope of [
|
||||
{ type: 'terminal' as const, targetId: 'terminal-b' },
|
||||
{ type: 'workspace' as const, targetId: 'workspace-b' },
|
||||
]) {
|
||||
test(`undo stays visible when a ${viewingScope.type} views a chat from a closed terminal`, async () => {
|
||||
const source = {
|
||||
...structuredClone(SOURCE_SESSION),
|
||||
scope: { type: 'terminal' as const, targetId: 'terminal-closed', hostIds: ['host-a'] },
|
||||
};
|
||||
const { dom, container, root, capture } = await setupAiState([source]);
|
||||
const sourceBefore = structuredClone(source);
|
||||
let branchId: string | undefined;
|
||||
await act(async () => {
|
||||
capture.ai!.setActiveSessionId('terminal:terminal-b', source.id);
|
||||
branchId = (await capture.ai!.undoLastTurnInSession(source.id, viewingScope))?.sessionId;
|
||||
});
|
||||
assert.ok(branchId);
|
||||
const branch = capture.sessions.find(item => item.id === branchId)!;
|
||||
assert.deepEqual(branch.messages, source.messages.slice(0, 2));
|
||||
assert.deepEqual(branch.scope, { ...source.scope, ...viewingScope });
|
||||
assert.equal(capture.activeSessionIdMap[`${viewingScope.type}:${viewingScope.targetId}`], branch.id);
|
||||
// The real history predicate must still include the selected branch after
|
||||
// the source terminal no longer belongs to the current workspace.
|
||||
assert.equal(getSessionScopeMatchRank(
|
||||
branch, viewingScope.type, viewingScope.targetId, ['host-a'],
|
||||
new Set([branch.id]), new Set(['terminal-b']), new Set([source.id]),
|
||||
), 3);
|
||||
const original = capture.sessions.find(item => item.id === source.id)!;
|
||||
assert.deepEqual({ ...original, updatedAt: sourceBefore.updatedAt }, sourceBefore);
|
||||
const saved = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!) as AISession[];
|
||||
assert.deepEqual(saved.find(item => item.id === branchId)?.scope, branch.scope);
|
||||
await act(async () => root.unmount());
|
||||
container.remove(); dom.window.close();
|
||||
});
|
||||
}
|
||||
@@ -852,7 +852,10 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
undoInFlightRef.current = true;
|
||||
setIsUndoingLastTurn(true);
|
||||
try {
|
||||
const result = await undoLastTurnInSession(sessionId);
|
||||
const result = await undoLastTurnInSession(sessionId, {
|
||||
type: scopeType,
|
||||
targetId: scopeTargetId,
|
||||
});
|
||||
if (!result) return;
|
||||
|
||||
ensureScopeDraft(currentAgentId);
|
||||
@@ -899,6 +902,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
undoLastTurnInSession,
|
||||
updateScopeDraft,
|
||||
currentAgentId,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
]);
|
||||
|
||||
const providersRef = useRef(providers);
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface AIChatSidePanelProps {
|
||||
/** Non-destructively branch before the last user turn; returns the branch id + restored composer content. */
|
||||
undoLastTurnInSession?: (
|
||||
sessionId: string,
|
||||
viewingScope?: Pick<AISessionScope, 'type' | 'targetId'>,
|
||||
) => Promise<{ sessionId: string; restored: { text: string; attachments: import('../infrastructure/ai/types').UploadedFile[]; selectedUserSkillSlugs: string[] } } | null>;
|
||||
deleteSession: (sessionId: string, scopeKey?: string) => void;
|
||||
updateSessionTitle: (sessionId: string, title: string) => void;
|
||||
|
||||
@@ -8,9 +8,6 @@ import { SearchAddon } from "@xterm/addon-search";
|
||||
import { SerializeAddon } from "@xterm/addon-serialize";
|
||||
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { supportsTerminalArchiveCommands } from "../../../domain/terminalArchives";
|
||||
import { registerTerminalArchiveLinks } from "./terminalArchiveLinks";
|
||||
import { isTerminalReadyForCommandInjection } from "./terminalCommandInjectionReadyRegistry";
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import { Terminal as XTerm } from "@xterm/xterm";
|
||||
import type { RefObject } from "react";
|
||||
@@ -317,7 +314,6 @@ export const resetKittyKeyboardModeStateForSession = (
|
||||
export type CreateXTermRuntimeContext = {
|
||||
container: HTMLDivElement;
|
||||
host: Host;
|
||||
getHost?: () => Host;
|
||||
fontFamilyId: string;
|
||||
resolvedFontFamily: string;
|
||||
fontSize: number;
|
||||
@@ -958,13 +954,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
terminalLinkHandler.activate(event, uri);
|
||||
});
|
||||
term.loadAddon(webLinksAddon);
|
||||
const archiveLinks = registerTerminalArchiveLinks({
|
||||
term,
|
||||
isEnabled: () => ctx.statusRef.current === 'connected'
|
||||
&& !ctx.isBroadcastEnabledRef.current
|
||||
&& supportsTerminalArchiveCommands(ctx.getHost?.() ?? ctx.host, platform),
|
||||
isInputReady: () => isTerminalReadyForCommandInjection(ctx.sessionId),
|
||||
});
|
||||
const pluginLinkProviderHost = ctx.requestPluginTerminalProviders
|
||||
? registerPluginTerminalLinkProvider({
|
||||
term,
|
||||
@@ -3489,7 +3478,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
keywordHighlighter.dispose();
|
||||
cursorLineHighlighter.dispose();
|
||||
pluginLinkProviderHost?.dispose();
|
||||
archiveLinks.dispose();
|
||||
pluginProviderHost?.dispose();
|
||||
eraseScrollbackDisposable.dispose();
|
||||
for (const disposable of cursorPositionReportRequestDisposables) {
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { ILink, ILinkProvider, Terminal } from '@xterm/xterm';
|
||||
import { registerTerminalArchiveLinks } from './terminalArchiveLinks';
|
||||
|
||||
function fixture() {
|
||||
let provider!: ILinkProvider;
|
||||
let text = 'backup.tar.gz';
|
||||
let enabled = true, ready = true, registrationDisposed = false, wrapped = false;
|
||||
const pasted: string[] = [];
|
||||
let submitted: (data: string) => void = () => {};
|
||||
let markerLine = -1;
|
||||
const term = {
|
||||
buffer: { active: { getLine: (y: number) => y === 0 ? { translateToString: () => text, isWrapped: wrapped } : undefined } },
|
||||
paste: (value: string) => { pasted.push(value); ready = false; },
|
||||
focus: () => {},
|
||||
registerMarker: () => ({ line: markerLine, isDisposed: false, dispose() { this.isDisposed = true; } }),
|
||||
onData: (listener: (data: string) => void) => { submitted = listener; return { dispose() {} }; },
|
||||
registerLinkProvider: (next: ILinkProvider) => { provider = next; return { dispose: () => { registrationDisposed = true; } }; },
|
||||
};
|
||||
const registration = registerTerminalArchiveLinks({ term: term as unknown as Terminal, isEnabled: () => enabled, isInputReady: () => ready });
|
||||
const links = () => { let value: ILink[] | undefined; provider.provideLinks(1, result => { value = result; }); return value; };
|
||||
return { links, pasted, registration, submit: () => { markerLine = 0; submitted('\r'); }, setText: (value: string) => { text = value; }, setReady: (value: boolean) => { ready = value; }, setEnabled: (value: boolean) => { enabled = value; }, setWrapped: () => { wrapped = true; }, isDisposed: () => registrationDisposed };
|
||||
}
|
||||
|
||||
const ctrlClick = { button: 0, ctrlKey: true } as MouseEvent;
|
||||
|
||||
test('Ctrl+left click prepares exactly one command without Enter; ordinary click does nothing', () => {
|
||||
const f = fixture(), link = f.links()![0];
|
||||
assert.deepEqual(link.range, { start: { x: 1, y: 1 }, end: { x: 13, y: 1 } });
|
||||
link.activate({ button: 0, ctrlKey: false } as MouseEvent, link.text);
|
||||
assert.deepEqual(f.pasted, []);
|
||||
link.activate(ctrlClick, link.text);
|
||||
link.activate(ctrlClick, link.text);
|
||||
assert.deepEqual(f.pasted, ["tar -xzf './backup.tar.gz'"]);
|
||||
});
|
||||
|
||||
test('live readiness/platform checks preserve a draft even for cached links', () => {
|
||||
const f = fixture(), link = f.links()![0];
|
||||
f.setReady(false);
|
||||
assert.equal(f.links(), undefined);
|
||||
link.activate(ctrlClick, link.text);
|
||||
f.setReady(true); f.setEnabled(false);
|
||||
link.activate(ctrlClick, link.text);
|
||||
assert.deepEqual(f.pasted, []);
|
||||
});
|
||||
|
||||
test('stale output and disposed links cannot prepare commands; wrapped fragments are excluded', () => {
|
||||
const f = fixture(), link = f.links()![0];
|
||||
f.setText('changed.tar.gz'); link.activate(ctrlClick, link.text);
|
||||
assert.deepEqual(f.pasted, []);
|
||||
f.setWrapped(); assert.equal(f.links(), undefined);
|
||||
f.registration.dispose(); assert.equal(f.isDisposed(), true);
|
||||
link.activate(ctrlClick, link.text); assert.deepEqual(f.pasted, []);
|
||||
});
|
||||
|
||||
test('submitting another command invalidates old relative names and cached links, but not absolute paths', () => {
|
||||
const f = fixture(), link = f.links()![0];
|
||||
f.submit();
|
||||
assert.deepEqual(f.links(), []);
|
||||
link.activate(ctrlClick, link.text);
|
||||
assert.deepEqual(f.pasted, []);
|
||||
f.setText('/tmp/backup.tar.gz');
|
||||
const absolute = f.links()![0];
|
||||
absolute.activate(ctrlClick, absolute.text);
|
||||
assert.deepEqual(f.pasted, ["tar -xzf '/tmp/backup.tar.gz'"]);
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { IDisposable, ILink, Terminal } from '@xterm/xterm';
|
||||
import { findTerminalArchives } from '../../../domain/terminalArchives';
|
||||
import { pluginTerminalCellRange, readPluginTerminalBufferText } from '../pluginTerminalBufferText';
|
||||
|
||||
/** Archive commands are prepared only at an empty shell prompt, never submitted. */
|
||||
export function registerTerminalArchiveLinks(options: {
|
||||
term: Terminal;
|
||||
isEnabled: () => boolean;
|
||||
isInputReady: () => boolean;
|
||||
}): IDisposable {
|
||||
let disposed = false;
|
||||
const { term } = options;
|
||||
// Relative names belong only to output from the latest submitted command.
|
||||
// A marker follows scrollback trimming without keeping a cwd history.
|
||||
let commandStart = term.registerMarker(0);
|
||||
const submission = term.onData(data => {
|
||||
if (!/[\r\n]/u.test(data)) return;
|
||||
commandStart?.dispose();
|
||||
commandStart = term.registerMarker(0);
|
||||
});
|
||||
const hasRelativeContext = (y: number) => commandStart !== undefined
|
||||
&& !commandStart.isDisposed && y - 1 > commandStart.line;
|
||||
const registration = term.registerLinkProvider({
|
||||
provideLinks(y, callback) {
|
||||
if (disposed || !options.isEnabled() || !options.isInputReady()) {
|
||||
callback(undefined);
|
||||
return;
|
||||
}
|
||||
const line = term.buffer.active.getLine(y - 1);
|
||||
// Do not mistake a fragment of a wrapped filename for a complete archive.
|
||||
if (!line || line.isWrapped || term.buffer.active.getLine(y)?.isWrapped) {
|
||||
callback(undefined);
|
||||
return;
|
||||
}
|
||||
const printed = readPluginTerminalBufferText(line, true);
|
||||
if (printed.text.length > 8192) {
|
||||
callback(undefined);
|
||||
return;
|
||||
}
|
||||
const links: ILink[] = findTerminalArchives(printed.text).flatMap((match) => {
|
||||
if (match.relative && !hasRelativeContext(y)) return [];
|
||||
const cells = pluginTerminalCellRange(printed, match.start, match.length);
|
||||
if (!cells) return [];
|
||||
return [{
|
||||
range: { start: { x: cells.x + 1, y }, end: { x: cells.x + cells.width, y } },
|
||||
text: match.text,
|
||||
decorations: { pointerCursor: true, underline: true },
|
||||
activate(event) {
|
||||
if (disposed || event.button !== 0 || !event.ctrlKey || !options.isEnabled() || !options.isInputReady()) return;
|
||||
if (match.relative && !hasRelativeContext(y)) return;
|
||||
if (term.buffer.active.getLine(y - 1)?.translateToString(true) !== printed.text) return;
|
||||
term.paste(match.command);
|
||||
term.focus();
|
||||
},
|
||||
}];
|
||||
});
|
||||
callback(links);
|
||||
},
|
||||
});
|
||||
return { dispose() { disposed = true; submission.dispose(); commandStart?.dispose(); registration.dispose(); } };
|
||||
}
|
||||
@@ -215,8 +215,6 @@ export function resolveSelectionOverlayPosition(term: any, container: HTMLElemen
|
||||
export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, broadcastPasswordBypassRef, isComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef, onPluginRuntimeCwdChange, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRules, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, onSnippetShortkeyRef, snippetsRef, splitResizeActive, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState, vaultInitialized } = ctx;
|
||||
const effectiveTerminalProtocol = resolveEffectiveTerminalProtocol(host);
|
||||
const archiveHostRef = useRef(host);
|
||||
archiveHostRef.current = host;
|
||||
const hibernateHiddenTabs = resolveTerminalHibernateEnabledForProtocol(
|
||||
terminalSettings,
|
||||
effectiveTerminalProtocol,
|
||||
@@ -519,7 +517,6 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
const runtime = createXTermRuntime({
|
||||
container: containerRef.current,
|
||||
host,
|
||||
getHost: () => archiveHostRef.current,
|
||||
fontFamilyId,
|
||||
resolvedFontFamily,
|
||||
fontSize,
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { findTerminalArchives, supportsTerminalArchiveCommands } from './terminalArchives';
|
||||
import type { Host } from './models';
|
||||
|
||||
const host: Host = { id: 'test', label: 'Test', hostname: 'host', username: 'me', tags: [], os: 'linux' };
|
||||
|
||||
test('archive commands require known Linux facts, not the default host icon', () => {
|
||||
assert.equal(supportsTerminalArchiveCommands(host, 'darwin'), false);
|
||||
assert.equal(supportsTerminalArchiveCommands({ ...host, distro: 'ubuntu' }, 'darwin'), true);
|
||||
assert.equal(supportsTerminalArchiveCommands({ ...host, osOverride: 'linux' }, 'darwin'), true);
|
||||
for (const osOverride of ['unknown', 'windows', 'macos', 'freebsd'] as const) {
|
||||
assert.equal(supportsTerminalArchiveCommands({ ...host, osOverride, distro: 'ubuntu' }, 'linux'), false);
|
||||
}
|
||||
assert.equal(supportsTerminalArchiveCommands({ ...host, distro: 'ubuntu', deviceType: 'network' }, 'linux'), false);
|
||||
for (const protocol of ['telnet', 'serial', 'plugin:test'] as const) {
|
||||
assert.equal(supportsTerminalArchiveCommands({ ...host, protocol, osOverride: 'linux' }, 'linux'), false);
|
||||
}
|
||||
assert.equal(supportsTerminalArchiveCommands({ ...host, protocol: 'local' }, 'darwin'), false);
|
||||
assert.equal(supportsTerminalArchiveCommands({ ...host, protocol: 'local' }, 'win32'), false);
|
||||
assert.equal(supportsTerminalArchiveCommands({ ...host, protocol: 'local' }, 'linux'), true);
|
||||
});
|
||||
|
||||
test('finite tar and zip formats prepare commands without Enter or source deletion', () => {
|
||||
const commands = findTerminalArchives('a.tar b.tar.gz c.tgz d.tar.bz2 e.tbz2 f.tar.xz g.txz h.zip').map(x => x.command);
|
||||
assert.deepEqual(commands, [
|
||||
"tar -xf './a.tar'", "tar -xzf './b.tar.gz'", "tar -xzf './c.tgz'",
|
||||
"tar -xjf './d.tar.bz2'", "tar -xjf './e.tbz2'", "tar -xJf './f.tar.xz'", "tar -xJf './g.txz'", "unzip './h.zip'",
|
||||
]);
|
||||
assert.deepEqual(findTerminalArchives('a.gz b.rar c.7z tool.exe https://host/a.zip'), []);
|
||||
assert.ok(commands.every(command => !/[\r\n]/u.test(command)));
|
||||
});
|
||||
|
||||
test('quoted filenames, Unicode and leading options remain literal shell arguments', () => {
|
||||
const text = `输出 "it's $(touch NO).tar.gz" '-options.zip' /opt/文件.tar ~/backup.zip`;
|
||||
const matches = findTerminalArchives(text);
|
||||
assert.equal(matches.length, 4);
|
||||
for (const match of matches) assert.equal(text.slice(match.start, match.start + match.length), match.text);
|
||||
const expected = ["./it's $(touch NO).tar.gz", './-options.zip', '/opt/文件.tar', `${process.env.HOME}/backup.zip`];
|
||||
for (let i = 0; i < matches.length; i++) {
|
||||
// Substitute only the fixed extractor name/options with printf: a real POSIX
|
||||
// shell must see the original filename as exactly one literal argument.
|
||||
const argument = matches[i].command.replace(/^(?:tar -x[zjJ]?f|unzip) /, '');
|
||||
assert.equal(execFileSync('/bin/sh', ['-c', `printf '%s' ${argument}`], { encoding: 'utf8' }), expected[i]);
|
||||
}
|
||||
});
|
||||
|
||||
test('does not guess partial tokens inside malformed quotes or include control characters', () => {
|
||||
assert.deepEqual(findTerminalArchives('"unfinished.tar.gz'), []);
|
||||
assert.deepEqual(findTerminalArchives('file\u001b.tar.gz'), []);
|
||||
assert.deepEqual(findTerminalArchives('"http://example.com/my archive.zip"'), []);
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import { resolveHostOs } from './host';
|
||||
import type { Host } from './models';
|
||||
|
||||
export function supportsTerminalArchiveCommands(host: Host, localPlatform: string): boolean {
|
||||
if (host.protocol === 'local') return localPlatform === 'linux';
|
||||
if (host.protocol && !['ssh', 'mosh', 'et'].includes(host.protocol)) return false;
|
||||
return host.deviceType !== 'network' && resolveHostOs(host) === 'linux';
|
||||
}
|
||||
|
||||
const TAR_OPTIONS: Record<string, string> = {
|
||||
'.tar': '-xf',
|
||||
'.tar.gz': '-xzf',
|
||||
'.tgz': '-xzf',
|
||||
'.tar.bz2': '-xjf',
|
||||
'.tbz2': '-xjf',
|
||||
'.tar.xz': '-xJf',
|
||||
'.txz': '-xJf',
|
||||
};
|
||||
|
||||
function archiveCommand(file: string): string | null {
|
||||
if (Array.from(file).some(char => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)
|
||||
|| /^[a-z][a-z0-9+.-]*:/iu.test(file)) return null;
|
||||
const suffix = Object.keys(TAR_OPTIONS).find((extension) => file.endsWith(extension));
|
||||
if (!suffix && !file.endsWith('.zip')) return null;
|
||||
// Prefix relative paths so an archive named "-options.zip" is never an option.
|
||||
const path = file.startsWith('/') || file.startsWith('~/') ? file : `./${file}`;
|
||||
const quote = (value: string) => `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||
const argument = path.startsWith('~/') ? `"$HOME"/${quote(path.slice(2))}` : quote(path);
|
||||
return suffix ? `tar ${TAR_OPTIONS[suffix]} ${argument}` : `unzip ${argument}`;
|
||||
}
|
||||
|
||||
export interface TerminalArchiveMatch {
|
||||
start: number;
|
||||
length: number;
|
||||
text: string;
|
||||
command: string;
|
||||
relative: boolean;
|
||||
}
|
||||
|
||||
/** One printed token, or an explicitly quoted filename containing spaces. */
|
||||
export function findTerminalArchives(line: string): TerminalArchiveMatch[] {
|
||||
const tokens = /(^|\s)(?:"([^"\r\n]+)"|'([^'\r\n]+)'|([^\s"']+))(?=\s|$)/gu;
|
||||
return Array.from(line.matchAll(tokens)).flatMap((match) => {
|
||||
const text = match[0].slice(match[1].length);
|
||||
const file = match[2] ?? match[3] ?? match[4];
|
||||
const command = archiveCommand(file);
|
||||
return command ? [{ start: match.index + match[1].length, length: text.length, text, command,
|
||||
relative: !file.startsWith('/') && !file.startsWith('~/'),
|
||||
}] : [];
|
||||
});
|
||||
}
|
||||
@@ -967,6 +967,25 @@ test("posix wrapper keeps cd contained in the subshell (documented trade-off)",
|
||||
}
|
||||
});
|
||||
|
||||
// A cancelled paced delivery may execute only the first physical line.
|
||||
// Neither partial delivery nor a complete command may change user shell hooks.
|
||||
for (const [partial, hasPS0] of [[true, true], [false, true], [true, false], [false, false]]) {
|
||||
test(`posix ${partial ? "partial" : "complete"} wrapper preserves ${hasPS0 ? "set" : "unset"} PS0 and existing shell state`, () => {
|
||||
const marker = "__NCMCP_TEST__";
|
||||
const wrapped = buildWrappedCommand("echo HOOK_STATE_TEST", "posix", marker);
|
||||
const script = partial ? wrapped.slice(0, wrapped.indexOf("\n") + 1) : wrapped;
|
||||
const result = spawnSync("bash", ["--norc", "-c",
|
||||
(hasPS0 ? 'PS0="[hook] "; ' : 'unset PS0; ') + '__NCMCP_PS0=user_value\n' + script
|
||||
+ 'printf "HOOK_STATE=<%s> SCRATCH=<%s>\\n" "$PS0" "$__NCMCP_PS0"',
|
||||
], { encoding: "utf8" });
|
||||
|
||||
assert.equal(result.error, undefined);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.ok(result.stdout.includes(`HOOK_STATE=<${hasPS0 ? "[hook] " : ""}> SCRATCH=<user_value>`), result.stdout);
|
||||
if (!partial) assert.match(result.stdout, new RegExp(`${marker}_E:0`));
|
||||
});
|
||||
}
|
||||
|
||||
// Issue #3265: with plain HISTCONTROL the wrapper line is recorded in bash
|
||||
// history; arrow-up recall then redraws the marker text, the preload __NCMCP_
|
||||
// filter suppresses the redraw fragments containing it, and readline's row
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { generateText, streamText } from "ai";
|
||||
import { buildCattySdkMessages, createContinuationContext } from "./harness/turnDrivers/cattyMessageBuilder";
|
||||
import { ToolOutputStore } from "./harness/toolOutputStore";
|
||||
|
||||
import { createModelFromConfig, resolveProviderEndpoint } from "./sdk/providers";
|
||||
import type { ProviderConfig } from "./types";
|
||||
@@ -280,3 +283,43 @@ test("SDK requests forward custom headers and scope OpenCode sessions", async (t
|
||||
}
|
||||
assert.equal(requests.length, 16);
|
||||
});
|
||||
|
||||
test("OpenAI Chat sends inline image attachments as data URLs through the bridge", async (t) => {
|
||||
const browserGlobal = globalThis as typeof globalThis & { window?: unknown };
|
||||
const originalWindow = browserGlobal.window;
|
||||
t.after(() => { browserGlobal.window = originalWindow; });
|
||||
const requests: Array<{ url: string; body: Record<string, unknown> }> = [];
|
||||
Reflect.set(globalThis, "window", { netcatty: {
|
||||
aiFetch: async (url: string, _method: string, _headers: Record<string, string>, body: string) => {
|
||||
requests.push({ url, body: JSON.parse(body) });
|
||||
return { ok: false, status: 400, data: '{"error":{"message":"captured request"}}' };
|
||||
},
|
||||
aiChatStream: async (_requestId: string, url: string, _headers: Record<string, string>, body: string) => {
|
||||
requests.push({ url, body: JSON.parse(body) });
|
||||
return { ok: false, error: "captured request" };
|
||||
},
|
||||
onAiStreamData: () => () => {},
|
||||
onAiStreamEnd: () => () => {},
|
||||
onAiStreamError: () => () => {},
|
||||
} });
|
||||
|
||||
for (const [mediaType, base64Data] of [["image/png", "iVBORw0KGgo="], ["image/jpeg", "/9j/4A=="]]) {
|
||||
const messages = buildCattySdkMessages({
|
||||
allMessages: [], includeCurrentUserMessage: true, trimmed: "Read this image",
|
||||
attachments: [{ mediaType, base64Data }],
|
||||
continuationContext: createContinuationContext("p", "openai", "m", false),
|
||||
chatSessionId: "images", toolOutputStore: new ToolOutputStore(), fieldsByMessage: new Map(),
|
||||
});
|
||||
const model = createModelFromConfig(makeConfig({ openaiApi: "chat", apiKey: "test-key", baseURL: "https://example.test/v1" }));
|
||||
await assert.rejects(generateText({ model, messages, maxRetries: 0 }), /captured request/);
|
||||
await assert.rejects(async () => await streamText({ model, messages, maxRetries: 0, onError: () => {} }).text);
|
||||
for (const request of requests.slice(-2)) {
|
||||
assert.equal(request.url, "https://example.test/v1/chat/completions");
|
||||
assert.deepEqual(request.body.messages, [{ role: "user", content: [
|
||||
{ type: "text", text: "Read this image" },
|
||||
{ type: "image_url", image_url: { url: `data:${mediaType};base64,${base64Data}` } },
|
||||
] }]);
|
||||
}
|
||||
}
|
||||
assert.equal(requests.length, 4);
|
||||
});
|
||||
@@ -1077,6 +1077,132 @@ for (const rejects of [false, true]) {
|
||||
});
|
||||
}
|
||||
|
||||
for (const rejects of [false, true]) {
|
||||
test(`final failure notification and ${rejects ? "rejection" : "reply"} cannot restart an exhausted cycle`, async (t) => {
|
||||
let statusListener: ((status: PortForwardingRule["status"], error?: string) => void) | undefined;
|
||||
let forceFinalAttempt = true;
|
||||
const reconnectRule = rule({ id: `final-double-failure-${rejects}`, autoStart: true });
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: {
|
||||
startPortForward: async () => {
|
||||
if (forceFinalAttempt) getActiveConnection(reconnectRule.id)!.reconnectAttempts = 5;
|
||||
statusListener?.("error", "SSH connection failed");
|
||||
if (rejects) throw new Error("SSH connection failed");
|
||||
return { success: false, error: "SSH connection failed" };
|
||||
},
|
||||
stopPortForwardByRuleId: async () => ({ stopped: 1, failed: 0, errors: [] }),
|
||||
onPortForwardStatus: (_id: string, listener: typeof statusListener) => {
|
||||
statusListener = listener;
|
||||
return () => undefined;
|
||||
},
|
||||
} },
|
||||
});
|
||||
let reconnects = 0;
|
||||
setReconnectCallback(async () => { reconnects += 1; return { success: true }; });
|
||||
t.after(async () => {
|
||||
setReconnectCallback(null);
|
||||
await stopAndCleanupRuleAndWait(reconnectRule.id);
|
||||
});
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
await startPortForward(reconnectRule, host(), [], [], [], () => undefined, true);
|
||||
t.mock.timers.tick(3000);
|
||||
assert.equal(reconnects, 0);
|
||||
assert.equal(resetReconnectAttempts(reconnectRule.id), true);
|
||||
|
||||
// An explicit restart must still get a new retry cycle if it fails.
|
||||
forceFinalAttempt = false;
|
||||
await startPortForward(reconnectRule, host(), [], [], [], () => undefined, true);
|
||||
assert.equal(getActiveConnection(reconnectRule.id)?.reconnectAttempts, 1);
|
||||
t.mock.timers.tick(3000);
|
||||
assert.equal(reconnects, 1);
|
||||
});
|
||||
}
|
||||
|
||||
for (const adoption of ["sync", "reconcile"] as const) {
|
||||
test(`${adoption} starts a fresh retry cycle when adopting an active backend tunnel`, async (t) => {
|
||||
const reconnectRule = rule({ id: `exhausted-adoption-${adoption}`, autoStart: true });
|
||||
const listeners = new Map<string, (status: PortForwardingRule["status"]) => void>();
|
||||
const backendTunnel = { ruleId: reconnectRule.id, tunnelId: "fresh-backend-tunnel", status: "active" };
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: {
|
||||
startPortForward: async () => ({ success: true }),
|
||||
listPortForwards: async () => [backendTunnel],
|
||||
stopPortForwardByRuleId: async () => ({ stopped: 1, failed: 0, errors: [] }),
|
||||
onPortForwardStatus: (id: string, listener: (status: PortForwardingRule["status"]) => void) => {
|
||||
listeners.set(id, listener);
|
||||
return () => undefined;
|
||||
},
|
||||
} },
|
||||
});
|
||||
let reconnects = 0;
|
||||
setReconnectCallback(async () => { reconnects += 1; return { success: true }; }, () => true);
|
||||
t.after(async () => {
|
||||
setReconnectCallback(null);
|
||||
await stopAndCleanupRuleAndWait(reconnectRule.id);
|
||||
});
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
await startPortForward(reconnectRule, host(), [], [], [], () => undefined, true);
|
||||
const oldConnection = getActiveConnection(reconnectRule.id)!;
|
||||
oldConnection.reconnectAttempts = 5;
|
||||
const oldListener = listeners.get(oldConnection.tunnelId)!;
|
||||
oldListener("error");
|
||||
oldListener("inactive");
|
||||
assert.equal(resetReconnectAttempts(reconnectRule.id), true);
|
||||
|
||||
if (adoption === "sync") await syncWithBackend({ shouldReconnect: () => true });
|
||||
else await reconcileWithBackend();
|
||||
assert.equal(resetReconnectAttempts(reconnectRule.id), false);
|
||||
const replacement = getActiveConnection(reconnectRule.id)!;
|
||||
assert.equal(replacement.tunnelId, backendTunnel.tunnelId);
|
||||
oldListener("error");
|
||||
assert.equal(getActiveConnection(reconnectRule.id), replacement);
|
||||
listeners.get(backendTunnel.tunnelId)!("inactive");
|
||||
assert.equal(getActiveConnection(reconnectRule.id)?.reconnectAttempts, 1);
|
||||
t.mock.timers.tick(3000);
|
||||
assert.equal(reconnects, 1);
|
||||
});
|
||||
}
|
||||
|
||||
for (const adoption of ["sync", "reconcile"] as const) {
|
||||
for (const rejects of [false, true]) {
|
||||
test(`${adoption} preserves exhaustion if backend adoption ${rejects ? "rejects" : "finds a stopped tunnel"}`, async (t) => {
|
||||
const reconnectRule = rule({ id: `failed-adoption-${adoption}-${rejects}`, autoStart: true });
|
||||
let statusListener: ((status: PortForwardingRule["status"]) => void) | undefined;
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: {
|
||||
startPortForward: async () => ({ success: true }),
|
||||
listPortForwards: async () => [{ ruleId: reconnectRule.id, tunnelId: "stopped-before-subscribe", status: "active" }],
|
||||
subscribePortForward: async () => {
|
||||
if (rejects) throw new Error("backend unavailable during adoption");
|
||||
return { tunnelId: "stopped-before-subscribe", status: "inactive" };
|
||||
},
|
||||
stopPortForwardByRuleId: async () => ({ stopped: 1, failed: 0, errors: [] }),
|
||||
onPortForwardStatus: (_id: string, listener: typeof statusListener) => {
|
||||
statusListener = listener;
|
||||
return () => undefined;
|
||||
},
|
||||
} },
|
||||
});
|
||||
setReconnectCallback(async () => ({ success: true }), () => true);
|
||||
t.after(async () => {
|
||||
setReconnectCallback(null);
|
||||
await stopAndCleanupRuleAndWait(reconnectRule.id);
|
||||
});
|
||||
await startPortForward(reconnectRule, host(), [], [], [], () => undefined, true);
|
||||
getActiveConnection(reconnectRule.id)!.reconnectAttempts = 5;
|
||||
statusListener!("error");
|
||||
statusListener!("inactive");
|
||||
assert.equal(resetReconnectAttempts(reconnectRule.id), true);
|
||||
if (adoption === "sync") await syncWithBackend({ shouldReconnect: () => true });
|
||||
else await reconcileWithBackend();
|
||||
assert.equal(resetReconnectAttempts(reconnectRule.id), true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("inactive close events preserve an already scheduled reconnect", async (t) => {
|
||||
let statusListener: ((status: PortForwardingRule["status"], error?: string | null) => void) | undefined;
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
|
||||
@@ -234,6 +234,10 @@ const scheduleReconnectIfNeeded = (
|
||||
return true;
|
||||
}
|
||||
|
||||
// One failed dial can report both a status event and a rejected start.
|
||||
// Once exhausted, neither notification may begin another retry cycle.
|
||||
if (exhaustedReconnectRules.has(ruleId)) return false;
|
||||
|
||||
const currentConn = activeConnections.get(ruleId);
|
||||
if (currentConn?.reconnectSuppressed) return false;
|
||||
if (currentConn?.reconnectTimerCallback) {
|
||||
@@ -681,6 +685,10 @@ const subscribeSyncedConnection = async (
|
||||
connection.status = resolveBackendStatus(snapshot.status);
|
||||
connection.error = snapshot.error;
|
||||
}
|
||||
if (connection.status === 'active') {
|
||||
exhaustedReconnectRules.delete(ruleId);
|
||||
connection.reconnectAttempts = 0;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -820,6 +828,10 @@ export const reconcileWithBackend = async (): Promise<{
|
||||
if (existing.status !== backendStatus || existing.error !== tunnel.error) {
|
||||
existing.status = backendStatus;
|
||||
existing.error = tunnel.error;
|
||||
if (backendStatus === 'active') {
|
||||
exhaustedReconnectRules.delete(ruleId);
|
||||
existing.reconnectAttempts = 0;
|
||||
}
|
||||
result.appeared.push(ruleId);
|
||||
}
|
||||
}
|
||||
@@ -893,7 +905,9 @@ export const startPortForward = async (
|
||||
|
||||
// Clear any existing reconnect timer
|
||||
clearReconnectTimer(rule.id);
|
||||
|
||||
// Explicit starts (including network recovery) authorize a fresh cycle.
|
||||
exhaustedReconnectRules.delete(rule.id);
|
||||
|
||||
if (!bridge?.startPortForward) {
|
||||
// Fallback for browser/dev mode - simulate the connection
|
||||
logger.warn('[PortForwardingService] Backend not available, simulating connection...');
|
||||
|
||||
Generated
+36
-6
@@ -16,7 +16,7 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^4.0.0",
|
||||
"@ai-sdk/google": "^4.0.0",
|
||||
"@ai-sdk/openai": "^4.0.0",
|
||||
"@ai-sdk/openai": "4.0.8",
|
||||
"@aws-sdk/client-s3": "^3.956.0",
|
||||
"@eslint-community/regexpp": "4.12.2",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
@@ -197,13 +197,43 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/openai": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-4.0.0.tgz",
|
||||
"integrity": "sha512-XcI/bJEG+Ymx8ZwQMY9GE9waHdEcSzT6wn2o+YW80hOQPf8IAGQvdNWKaD0mxLi6AmOM0L01y/p626w7rImblQ==",
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-4.0.8.tgz",
|
||||
"integrity": "sha512-yW2UuS6jlu2LRYCd+cSpVdu/umf+QjypihYAiGvKk5HnpqQf21ffNjFbke9xo9jhT+TBt2YR30Fs5Sy0md/dfA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "4.0.0",
|
||||
"@ai-sdk/provider-utils": "5.0.0"
|
||||
"@ai-sdk/provider": "4.0.2",
|
||||
"@ai-sdk/provider-utils": "5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.76 || ^4.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/openai/node_modules/@ai-sdk/provider": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.2.tgz",
|
||||
"integrity": "sha512-pfPoy9J1B1xV7cqJ8MYHOsDYrMv5tR3+EMNfI249OhkD2uRakvav3Fo7XpD2luuN/YNCBY7KfEQc7vEV7KEtyw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"json-schema": "^0.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/openai/node_modules/@ai-sdk/provider-utils": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.5.tgz",
|
||||
"integrity": "sha512-oI0t3dvCoqWNV1I8o1Rybi2DXDvHES5r/TrwtJW90tuFLVepgJlftPxrcjh8vaSvjqC2diTuA2vXyjKAyHJm4A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "4.0.2",
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@workflow/serde": "4.1.0",
|
||||
"eventsource-parser": "^3.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^4.0.0",
|
||||
"@ai-sdk/google": "^4.0.0",
|
||||
"@ai-sdk/openai": "^4.0.0",
|
||||
"@ai-sdk/openai": "4.0.8",
|
||||
"@aws-sdk/client-s3": "^3.956.0",
|
||||
"@eslint-community/regexpp": "4.12.2",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
|
||||
Reference in new issue
Block a user