mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-10 16:21:22 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
814ea3a71e | ||
|
|
cfbd47f1bb |
No files matched your search
@@ -534,9 +534,10 @@ async function captureCtxInheritedCwd(getCtx: AppContextGetter, sessionId: strin
|
||||
const source = sessions?.find((s: { id: string }) => s.id === sessionId);
|
||||
if (!source) return undefined;
|
||||
|
||||
// Freshest cwd: the live OSC 7 value tracked in terminal state (the only
|
||||
// source that reflects `cd`s in a running local terminal — lastCwd is a
|
||||
// startup snapshot). Falls through to the SSH probe / lastCwd when absent.
|
||||
// Freshest cwd: the live value tracked in terminal state (OSC 7 or the
|
||||
// post-command probe, including local shells). lastCwd on the session object
|
||||
// is only the startup snapshot. Falls through to the SSH probe / lastCwd
|
||||
// when absent.
|
||||
const liveCwd: string | undefined = getSessionRestoreCwd?.(sessionId);
|
||||
|
||||
const bridge = netcattyBridge?.get?.();
|
||||
|
||||
@@ -13,10 +13,11 @@ type CaptureSession = Pick<TerminalSession, "id" | "protocol" | "status" | "last
|
||||
|
||||
export interface CaptureInheritedCwdOptions {
|
||||
/**
|
||||
* The session's live tracked cwd (OSC 7), sourced from the terminal-state
|
||||
* cwd map rather than the session object. This is the freshest value and the
|
||||
* only one that reflects `cd`s in a running LOCAL terminal (whose live cwd is
|
||||
* never mirrored onto `TerminalSession.lastCwd`).
|
||||
* The session's live tracked cwd (OSC 7 or the post-command cwd probe),
|
||||
* sourced from the terminal-state cwd map rather than the session object.
|
||||
* This is the freshest value and the one that reflects `cd`s in a running
|
||||
* local terminal. The live value stays off `TerminalSession.lastCwd` until
|
||||
* session restore persists it.
|
||||
*/
|
||||
liveCwd?: string;
|
||||
/**
|
||||
@@ -35,7 +36,7 @@ export const DEFAULT_INHERITED_CWD_PROBE_TIMEOUT_MS = 1500;
|
||||
/**
|
||||
* Resolve the working directory a clone/split should inherit from its source.
|
||||
*
|
||||
* Priority: live tracked cwd (OSC 7) -> live SSH `/proc` probe (when allowed)
|
||||
* Priority: live tracked cwd (OSC 7 or post-command probe) -> live SSH `/proc` probe (when allowed)
|
||||
* -> tracked `lastCwd` snapshot -> local `localStartDir`. The probe is raced
|
||||
* against a short timeout so a slow/wedged connection can't block tab creation,
|
||||
* and is skipped entirely when `allowSshProbe` is false. Returns undefined when
|
||||
|
||||
@@ -2430,7 +2430,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const pluginAwareOnCommandCompleted = useCallback(() => {
|
||||
pluginTerminalLifecycle.onCommandCompleted();
|
||||
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
|
||||
}, [pluginTerminalLifecycle]);
|
||||
// Hibernate wake builds its runtime from this callback. Same local
|
||||
// prompt-return refresh as useTerminalEffects (#1952).
|
||||
if (host.protocol === "local") {
|
||||
onCommandSubmitted?.("", host.id, host.label, sessionId);
|
||||
}
|
||||
}, [host.id, host.label, host.protocol, onCommandSubmitted, pluginTerminalLifecycle, sessionId]);
|
||||
const pluginAwareOnTerminalCwdChange = useCallback((
|
||||
changedSessionId: string,
|
||||
cwd: string | null,
|
||||
@@ -4654,7 +4659,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onWake: wakeFromHibernateRuntime,
|
||||
});
|
||||
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, snapshotUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts: resolvedChainHosts, 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: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted: cwdAwareOnCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory: terminalOutputHistoryRef.current, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, snapshotUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts: resolvedChainHosts, 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: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted: cwdAwareOnCommandSubmitted, onCommandSettled: onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory: terminalOutputHistoryRef.current, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { FolderTree, History, MessageSquare, PanelLeft, PanelRight, Palette, X,
|
||||
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { activeTabStore } from '../application/state/activeTabStore';
|
||||
import { getScriptRecordingSnapshot } from '../application/state/scriptRecordingStore.ts';
|
||||
import { canReuseTerminalConnection } from '../application/state/terminalConnectionReuse';
|
||||
import { resolveTerminalSessionExitIntent, type TerminalSessionExitEvent } from '../application/state/resolveTerminalSessionExitIntent';
|
||||
import { prewarmAIStateStorageSnapshots } from '../application/state/aiStateSnapshots';
|
||||
import {
|
||||
@@ -114,7 +113,7 @@ import {
|
||||
DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
resolveSessionSidePanelAutoOpen,
|
||||
} from '../domain/terminalSidePanelAutoOpen';
|
||||
import { shouldProbeCommandCwd } from './terminalLayer/commandCwdProbe';
|
||||
import { canScheduleCommandCwdProbe, shouldProbeCommandCwd } from './terminalLayer/commandCwdProbe';
|
||||
import {
|
||||
resolvePreferredTerminalCwd,
|
||||
scheduleBackendCwdProbeAfterCommand,
|
||||
@@ -1348,7 +1347,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
|
||||
const tabId = activeTabIdRef.current;
|
||||
const session = sessionsRef.current.find((candidate) => candidate.id === sessionId);
|
||||
if (!session || !canReuseTerminalConnection(session)) return;
|
||||
// Local POSIX shells do not emit OSC 7. They share this probe so session
|
||||
// restore can `cd` back after restart (#1952). Prompt return calls this
|
||||
// again with an empty command once the shell is idle, so a slow command
|
||||
// such as `sleep 1; cd /tmp` is not recorded at the 150ms snapshot.
|
||||
// SSH keeps the extra-exec eligibility check inside canScheduleCommandCwdProbe.
|
||||
if (!session || !canScheduleCommandCwdProbe(session)) return;
|
||||
const sessionHost = sessionHostsMapRef.current.get(sessionId);
|
||||
const visibleSftpHost = tabId && sidePanelLayoutHasTool(sidePanelLayoutsRef.current.get(tabId), 'sftp')
|
||||
? sftpHostForTabRef.current.get(tabId) ?? null
|
||||
@@ -1372,6 +1376,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
getSessionPwd: (id, options) => terminalBackend.getSessionPwd(id, options),
|
||||
canProbe: async () => {
|
||||
if (cwdProbeGenerationRef.current.get(sessionId) !== probeGeneration) return false;
|
||||
// Local cwd is the PTY process directory, not an extra SSH channel.
|
||||
if (session.protocol === "local") return true;
|
||||
const host = sessionHostsMapRef.current.get(sessionId);
|
||||
if (!host) return false;
|
||||
const hostDeviceClass = classifyDistroId(host.distro);
|
||||
|
||||
@@ -229,9 +229,21 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
publishPluginTerminalRuntimeLifecycleEvent(pluginTerminalLifecycle, 'commandSubmitted');
|
||||
void xtermRuntimeRef.current?.pluginProviderHost?.commandSubmitted(args[0]);
|
||||
};
|
||||
// The xterm runtime captures this callback at boot. Read the latest layer
|
||||
// handler through a ref so a prompt-return refresh sees the current
|
||||
// restore-cwd setting (#1952).
|
||||
const onCommandSettledRef = useRef(ctx.onCommandSettled);
|
||||
onCommandSettledRef.current = ctx.onCommandSettled;
|
||||
const pluginAwareOnCommandCompleted = () => {
|
||||
publishPluginTerminalRuntimeLifecycleEvent(pluginTerminalLifecycle, 'commandCompleted');
|
||||
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
|
||||
// Stock local shells update cwd when the command finishes, which can be
|
||||
// long after the 150ms post-submit probe. Prompt return (and OSC 133)
|
||||
// already call this hook; re-run the probe then. Empty command text keeps
|
||||
// coding-CLI detection from treating the refresh as a new launch.
|
||||
if (isLocalConnection) {
|
||||
onCommandSettledRef.current?.("", host.id, host.label, sessionId);
|
||||
}
|
||||
};
|
||||
const isRendererActive = isVisible || !hibernateHiddenTabs;
|
||||
const isRendererActiveRef = useRef(isRendererActive);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { shouldProbeCommandCwd } from "./commandCwdProbe";
|
||||
import { canScheduleCommandCwdProbe, shouldProbeCommandCwd } from "./commandCwdProbe";
|
||||
|
||||
test("probes command cwd for session restore even when the SFTP panel is not visible", () => {
|
||||
assert.equal(
|
||||
@@ -51,6 +52,73 @@ test("visible SFTP host override can disable command cwd probing", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("schedules the post-command cwd probe for a connected local POSIX shell", () => {
|
||||
assert.equal(
|
||||
canScheduleCommandCwdProbe({ protocol: "local", status: "connected", shellType: "posix" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
canScheduleCommandCwdProbe({ protocol: "local", status: "connected" }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("does not schedule a local cwd probe for Windows shells or a disconnected shell", () => {
|
||||
assert.equal(
|
||||
canScheduleCommandCwdProbe({ protocol: "local", status: "connected", shellType: "powershell" }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
canScheduleCommandCwdProbe({ protocol: "local", status: "connected", shellType: "cmd" }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
canScheduleCommandCwdProbe({ protocol: "local", status: "connecting", shellType: "posix" }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps the SSH post-command probe on reusable connections only", () => {
|
||||
assert.equal(canScheduleCommandCwdProbe({ protocol: "ssh", status: "connected" }), true);
|
||||
assert.equal(canScheduleCommandCwdProbe({ protocol: "ssh", status: "disconnected" }), false);
|
||||
assert.equal(
|
||||
canScheduleCommandCwdProbe({ protocol: "ssh", status: "connected", moshEnabled: true }),
|
||||
false,
|
||||
);
|
||||
assert.equal(canScheduleCommandCwdProbe({ protocol: "telnet", status: "connected" }), false);
|
||||
});
|
||||
|
||||
test("local command submission uses the shared cwd probe gate", () => {
|
||||
const source = readFileSync(new URL("../TerminalLayer.tsx", import.meta.url), "utf8");
|
||||
const start = source.indexOf("const handleCommandSubmitted = useCallback");
|
||||
const end = source.indexOf("const handleCommandExecuted = useCallback", start);
|
||||
const body = source.slice(start, end);
|
||||
assert.match(body, /canScheduleCommandCwdProbe\(session\)/);
|
||||
assert.match(body, /session\.protocol === "local"\) return true/);
|
||||
});
|
||||
|
||||
test("local prompt return re-runs the cwd probe after the command finishes", () => {
|
||||
const effects = readFileSync(new URL("../terminal/useTerminalEffects.ts", import.meta.url), "utf8");
|
||||
const completed = effects.slice(
|
||||
effects.indexOf("const pluginAwareOnCommandCompleted"),
|
||||
effects.indexOf("const isRendererActive ="),
|
||||
);
|
||||
assert.match(completed, /if \(isLocalConnection\)/);
|
||||
assert.match(completed, /onCommandSettledRef\.current\?\.\("", host\.id, host\.label, sessionId\)/);
|
||||
|
||||
const terminal = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
|
||||
const wakeCompleted = terminal.slice(
|
||||
terminal.indexOf("const pluginAwareOnCommandCompleted = useCallback"),
|
||||
terminal.indexOf("const pluginAwareOnTerminalCwdChange"),
|
||||
);
|
||||
assert.match(wakeCompleted, /host\.protocol === "local"/);
|
||||
assert.match(wakeCompleted, /onCommandSubmitted\?\.\("", host\.id, host\.label, sessionId\)/);
|
||||
assert.match(
|
||||
terminal,
|
||||
/onCommandSubmitted: cwdAwareOnCommandSubmitted, onCommandSettled: onCommandSubmitted/,
|
||||
);
|
||||
});
|
||||
|
||||
test("does not probe command cwd on single-channel SSH hosts", () => {
|
||||
assert.equal(
|
||||
shouldProbeCommandCwd({
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isTerminalSessionEligibleForSftpReuse } from "../../application/state/terminalConnectionReuse";
|
||||
import type { TerminalSession } from "../../domain/models";
|
||||
import { resolveHostFollowTerminalCwd, resolveSftpFollowTerminalCwdTargetHost } from "../../domain/sftpFollowTerminalCwd";
|
||||
|
||||
type FollowTerminalCwdHost = {
|
||||
@@ -12,6 +14,26 @@ type ShouldProbeCommandCwdOptions = {
|
||||
restrictExtraSshChannels?: boolean;
|
||||
};
|
||||
|
||||
type CommandCwdProbeSession = Pick<
|
||||
TerminalSession,
|
||||
"protocol" | "status" | "moshEnabled" | "etEnabled" | "shellType"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Sessions whose post-command cwd probe can run.
|
||||
*
|
||||
* SSH reuses the extra exec channel (`getSessionPwd`). Local POSIX shells have
|
||||
* no OSC 7 by default and no SSH channel, but the same probe path can read the
|
||||
* PTY process cwd. CMD/PowerShell cannot accept the POSIX restore `cd`.
|
||||
*/
|
||||
export const canScheduleCommandCwdProbe = (session: CommandCwdProbeSession): boolean => {
|
||||
if (session.status !== "connected") return false;
|
||||
if (session.protocol === "local") {
|
||||
return session.shellType !== "powershell" && session.shellType !== "cmd";
|
||||
}
|
||||
return isTerminalSessionEligibleForSftpReuse(session);
|
||||
};
|
||||
|
||||
export const shouldProbeCommandCwd = ({
|
||||
restoreTerminalCwd,
|
||||
visibleSftpHost,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
const fs = require("node:fs");
|
||||
const { execFile } = require("node:child_process");
|
||||
|
||||
const LSOF_TIMEOUT_MS = 2000;
|
||||
const LSOF_MAX_BUFFER_BYTES = 64 * 1024;
|
||||
|
||||
const isAbsolutePosixPath = (value) =>
|
||||
typeof value === "string" && value.startsWith("/") && !value.includes("\0");
|
||||
|
||||
/**
|
||||
* lsof appends "(readlink: Permission denied)" style notes to names it could
|
||||
* not resolve. Those are not directories the shell can `cd` into.
|
||||
*/
|
||||
const isAnnotatedLsofName = (name) =>
|
||||
/\s\([A-Za-z][A-Za-z0-9]*: .*\)$/.test(name);
|
||||
|
||||
/**
|
||||
* Parse `lsof -F n` (optionally with `t` type records) and return the cwd
|
||||
* name when lsof reports a directory.
|
||||
*/
|
||||
function parseLsofCwdOutput(stdout) {
|
||||
if (typeof stdout !== "string" || stdout.length === 0) return null;
|
||||
let name = null;
|
||||
let isDir = true;
|
||||
let sawType = false;
|
||||
for (const line of stdout.split("\n")) {
|
||||
if (line.startsWith("f")) {
|
||||
sawType = false;
|
||||
isDir = true;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("t")) {
|
||||
sawType = true;
|
||||
isDir = line === "tDIR" || line === "tVDIR";
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("n") && (!sawType || isDir)) {
|
||||
name = line.slice(1);
|
||||
}
|
||||
}
|
||||
if (!isAbsolutePosixPath(name) || isAnnotatedLsofName(name)) return null;
|
||||
return name;
|
||||
}
|
||||
|
||||
function localSessionPid(session) {
|
||||
const pid = session?.proc?.pid ?? session?.pty?.pid;
|
||||
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
||||
}
|
||||
|
||||
function readlinkProcCwd(pid, readlink) {
|
||||
return Promise.resolve(readlink(`/proc/${pid}/cwd`)).then(
|
||||
(cwd) => (isAbsolutePosixPath(cwd) ? cwd : null),
|
||||
() => null,
|
||||
);
|
||||
}
|
||||
|
||||
function execFileText(execFileFn, file, args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFileFn(
|
||||
file,
|
||||
args,
|
||||
{
|
||||
timeout: LSOF_TIMEOUT_MS,
|
||||
maxBuffer: LSOF_MAX_BUFFER_BYTES,
|
||||
windowsHide: true,
|
||||
},
|
||||
(err, stdout) => {
|
||||
if (typeof stdout === "string" && stdout.length > 0) {
|
||||
resolve(stdout);
|
||||
return;
|
||||
}
|
||||
reject(err || new Error("lsof produced no output"));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function readLsofCwd(pid, execFileFn, binaries) {
|
||||
const args = ["-a", "-p", String(pid), "-d", "cwd", "-Fn"];
|
||||
for (const bin of binaries) {
|
||||
try {
|
||||
const stdout = await execFileText(execFileFn, bin, args);
|
||||
const cwd = parseLsofCwdOutput(stdout);
|
||||
if (cwd) return cwd;
|
||||
} catch (err) {
|
||||
if (err && err.code === "ENOENT") continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function lsofBinaries(platform) {
|
||||
if (platform === "darwin") return ["/usr/sbin/lsof", "lsof"];
|
||||
return ["lsof"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Working directory of the local PTY shell Netcatty spawned.
|
||||
*
|
||||
* `cd` is a shell builtin, so the spawned process cwd changes even when the
|
||||
* shell never prints OSC 7. Linux reads `/proc/<pid>/cwd`; macOS uses lsof
|
||||
* because it has no procfs. Windows shells are not probed here.
|
||||
*/
|
||||
async function readLocalSessionCwd(session, deps = {}) {
|
||||
const pid = localSessionPid(session);
|
||||
if (!pid) return { success: false, error: "Local session has no process" };
|
||||
|
||||
const platform = deps.platform || process.platform;
|
||||
const readlink = deps.readlink || fs.promises.readlink.bind(fs.promises);
|
||||
const execFileFn = deps.execFile || execFile;
|
||||
|
||||
let cwd = null;
|
||||
if (platform === "linux") {
|
||||
cwd = await readlinkProcCwd(pid, readlink);
|
||||
}
|
||||
if (!cwd && (platform === "linux" || platform === "darwin")) {
|
||||
cwd = await readLsofCwd(pid, execFileFn, deps.lsofBinaries || lsofBinaries(platform));
|
||||
}
|
||||
if (!cwd) return { success: false, error: "Could not determine cwd" };
|
||||
return { success: true, cwd };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseLsofCwdOutput,
|
||||
readLocalSessionCwd,
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const { spawn } = require("node:child_process");
|
||||
|
||||
const { parseLsofCwdOutput, readLocalSessionCwd } = require("./localSessionCwd.cjs");
|
||||
|
||||
test("parseLsofCwdOutput accepts a directory name and ignores annotations", () => {
|
||||
assert.equal(
|
||||
parseLsofCwdOutput("p4242\nfcwd\ntDIR\nn/srv/app dir\n"),
|
||||
"/srv/app dir",
|
||||
);
|
||||
assert.equal(
|
||||
parseLsofCwdOutput("p4242\nfcwd\ntDIR\nn/proc/4242/cwd (readlink: Permission denied)\n"),
|
||||
null,
|
||||
);
|
||||
assert.equal(parseLsofCwdOutput("p4242\nfcwd\ntunknown\nn/tmp\n"), null);
|
||||
assert.equal(parseLsofCwdOutput(""), null);
|
||||
});
|
||||
|
||||
test("readLocalSessionCwd reads /proc cwd for a local shell pid", async () => {
|
||||
const result = await readLocalSessionCwd(
|
||||
{ protocol: "local", proc: { pid: 42 } },
|
||||
{
|
||||
platform: "linux",
|
||||
readlink: async (target) => {
|
||||
assert.equal(target, "/proc/42/cwd");
|
||||
return "/srv/project";
|
||||
},
|
||||
execFile: () => {
|
||||
throw new Error("lsof should not run after /proc succeeds");
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.deepEqual(result, { success: true, cwd: "/srv/project" });
|
||||
});
|
||||
|
||||
test("readLocalSessionCwd falls back to lsof on macOS", async () => {
|
||||
const calls = [];
|
||||
const result = await readLocalSessionCwd(
|
||||
{ type: "local", pty: { pid: 7 } },
|
||||
{
|
||||
platform: "darwin",
|
||||
execFile: (file, args, _options, callback) => {
|
||||
calls.push([file, args]);
|
||||
callback(Object.assign(new Error("missing"), { code: "ENOENT" }), "");
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(result.success, false);
|
||||
assert.deepEqual(calls[0][0], "/usr/sbin/lsof");
|
||||
assert.deepEqual(calls[1][0], "lsof");
|
||||
|
||||
const ok = await readLocalSessionCwd(
|
||||
{ protocol: "local", proc: { pid: 7 } },
|
||||
{
|
||||
platform: "darwin",
|
||||
lsofBinaries: ["lsof"],
|
||||
execFile: (_file, _args, _options, callback) => {
|
||||
callback(null, "p7\nfcwd\nn/Users/me/work\n");
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.deepEqual(ok, { success: true, cwd: "/Users/me/work" });
|
||||
});
|
||||
|
||||
test("readLocalSessionCwd does not guess a home directory", async () => {
|
||||
const missing = await readLocalSessionCwd({ protocol: "local" }, { platform: "linux" });
|
||||
assert.deepEqual(missing, { success: false, error: "Local session has no process" });
|
||||
|
||||
const windows = await readLocalSessionCwd(
|
||||
{ protocol: "local", proc: { pid: 9 } },
|
||||
{ platform: "win32", readlink: async () => "/tmp" },
|
||||
);
|
||||
assert.equal(windows.success, false);
|
||||
});
|
||||
|
||||
test("a real local bash cd is visible from the spawned pid", { skip: process.platform !== "linux" }, async () => {
|
||||
const expected = fs.realpathSync("/tmp");
|
||||
const child = spawn("/bin/bash", ["-c", "cd /tmp && exec sleep 30"], {
|
||||
cwd: "/",
|
||||
stdio: "ignore",
|
||||
});
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("spawn", resolve);
|
||||
});
|
||||
// spawn fires before bash runs cd. Keep reading until the directory
|
||||
// actually changes; the first successful /proc read is still "/".
|
||||
let cwd = null;
|
||||
const deadline = Date.now() + 2000;
|
||||
while (Date.now() < deadline) {
|
||||
const result = await readLocalSessionCwd({
|
||||
protocol: "local",
|
||||
proc: { pid: child.pid },
|
||||
});
|
||||
if (result.success) cwd = result.cwd;
|
||||
if (cwd === expected) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
assert.equal(cwd, expected);
|
||||
} finally {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
const { sampleSharedStats } = require("./sharedStats.cjs");
|
||||
const { GPU_ASSIGNMENT, parseGpuStats, withCachedMonitorGpu, cacheFullGpuStats, startMonitorGpuProbe } = require("./monitorGpuStats.cjs");
|
||||
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
|
||||
const { readLocalSessionCwd } = require("../localSessionCwd.cjs");
|
||||
const { listInteractiveShellPids } = require("../sshInteractiveShells.cjs");
|
||||
function extraExecUnsupportedError(session) {
|
||||
if (!session?.singleChannelSsh) return null;
|
||||
@@ -330,6 +331,12 @@ function createSessionOpsApi(ctx) {
|
||||
? Math.min(Math.max(requestedTimeoutMs, 100), 5000)
|
||||
: 5000;
|
||||
const session = sessions.get(sessionId);
|
||||
|
||||
// Local shells have no SSH exec channel. Read the PTY process cwd so a
|
||||
// stock bash `cd` is recorded for session restore without OSC 7 (#1952).
|
||||
if (session && (session.protocol === "local" || session.type === "local")) {
|
||||
return readLocalSessionCwd(session);
|
||||
}
|
||||
|
||||
if (!session || !session.conn) {
|
||||
return { success: false, error: 'Session not found or not connected' };
|
||||
|
||||
@@ -38,6 +38,31 @@ function makeApi(session, siblingSessions = [], overrides = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
test("getSessionPwd reads a local PTY cwd without an SSH connection", { skip: process.platform !== "linux" }, async () => {
|
||||
const api = makeApi({
|
||||
protocol: "local",
|
||||
type: "local",
|
||||
proc: { pid: process.pid },
|
||||
});
|
||||
|
||||
const result = await api.getSessionPwd(null, {
|
||||
sessionId: "session-1",
|
||||
allowHomeFallback: false,
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.cwd, fs.realpathSync(process.cwd()));
|
||||
});
|
||||
|
||||
test("getSessionPwd does not require an SSH connection for a local session", async () => {
|
||||
const api = makeApi({ protocol: "local", type: "local" });
|
||||
|
||||
const result = await api.getSessionPwd(null, { sessionId: "session-1" });
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.error, "Local session has no process");
|
||||
});
|
||||
|
||||
test("shared terminal cwd probe refuses to guess without a shell pid", async () => {
|
||||
let execCalls = 0;
|
||||
const connRef = { count: 2 };
|
||||
|
||||
Reference in new issue
Block a user