Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 814ea3a71e fix(terminal): re-probe local cwd when the prompt returns
The 150ms post-submit probe can record the directory from before a
slow command finishes. When a local shell's prompt comes back, run
the same probe again. The bash cwd test now waits until the process
is actually in /tmp.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 05:01:32 +00:00
Cursor Agentand陈大猫 cfbd47f1bb fix(terminal): record local shell cwd for session restore
Stock local bash never emits OSC 7, and the post-command cwd probe
was skipped because local sessions cannot reuse an SSH channel.
When restore-cwd is enabled, read the local PTY process directory
through the same probe path SSH already uses.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 04:50:01 +00:00
11 changed files with 393 additions and 14 deletions

No files matched your search

+4 -3
View File
@@ -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?.();
+6 -5
View File
@@ -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
+7 -2
View File
@@ -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 (
<>
+9 -3
View File
@@ -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);
+12
View File
@@ -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,
+126
View File
@@ -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,
};
+106
View File
@@ -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 };