Compare commits

...
Author SHA1 Message Date
陈大猫 d014532dc3 fix(terminal): chunk PTY OSC7 install under canonical line limit (#4052) 2026-10-29 12:29:23 +08:00
陈大猫 5df98841f0 fix(terminal): PTY OSC7 install when exec cannot verify shell (#4052)
On confirmed Configure directory tracking, route exec exit 4 identity failures through a dedicated inline foreground PTY transport, separate from #1942 /tmp staging.

Require empty pending input, probe the interactive bash/zsh shell, reuse the existing OSC7 setup script with FORCE_SHELL, and await a DONE acknowledgement before reporting success. Keep OTHER_USER staging and silent setup behavior unchanged.
2026-10-29 12:20:26 +08:00
3 changed files with 561 additions and 3 deletions

No files matched your search

+6 -2
View File
@@ -167,7 +167,7 @@ import {
} from "./terminal/runtime/createXTermRuntime";
import { clearKittyKeyboardBroadcastSession } from "./terminal/runtime/kittyKeyboardBroadcast";
import { registerTerminalSensitiveInputReader } from "./terminal/runtime/terminalSensitiveInputRegistry";
import { registerTerminalCommandInjectionReadyReader } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
import { isTerminalReadyForCommandInjection, registerTerminalCommandInjectionReadyReader } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
import {
isIdleShellReadyForCommandInjection,
isShellPromptReadyForInsertion,
@@ -206,7 +206,7 @@ import { useTerminalFilePaste } from "./terminal/hooks/useTerminalFilePaste";
import { getRememberedYmodemSendDefaultPath, rememberYmodemSendFilePath } from "../application/state/ymodemFileMemory";
import { TerminalAutocomplete } from "./terminal/TerminalAutocomplete";
import { resolveTerminalAutocompleteSettings } from "./terminal/autocomplete/terminalAutocompleteSettings";
import { buildOsc7SetupExecCommand, runOsc7SetupAction, shouldOfferOsc7SetupAction } from "./terminal/osc7Setup";
import { buildOsc7SetupExecCommand, runOsc7SetupAction, shouldOfferOsc7SetupAction, waitForOsc7SessionOutput } from "./terminal/osc7Setup";
import {
getRemoteClipboardImageUploadErrorMessageKey,
type RemoteClipboardImageUploadResult,
@@ -1390,6 +1390,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setupOsc7Tracking: terminalBackend.setupOsc7Tracking,
writeToSession: terminalBackend.writeToSession,
writeLocalTerminalData,
isTerminalReadyForCommandInjection: () => isTerminalReadyForCommandInjection(sessionId),
waitForSessionOutput: (id, predicate, timeoutMs) =>
waitForOsc7SessionOutput(terminalBackend.onSessionData, id, predicate, timeoutMs),
}).then((result) => {
handleOsc7SetupOpenChange(false);
if (result.success) {
@@ -1413,6 +1416,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
terminalCwdTracker,
terminalBackend.setupOsc7Tracking,
terminalBackend.writeToSession,
terminalBackend.onSessionData,
writeLocalTerminalData,
]);
+396
View File
@@ -1,4 +1,5 @@
import test from "node:test";
import * as pty from "node-pty";
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import {
@@ -17,10 +18,22 @@ import {
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { isIdleShellReadyForCommandInjection } from "../../domain/terminalCommandInjectionReady";
import {
isTerminalReadyForCommandInjection,
registerTerminalCommandInjectionReadyReader,
} from "./runtime/terminalCommandInjectionReadyRegistry";
import {
OSC7_MARKER,
OSC7_SETUP_OTHER_USER_MARKER,
OSC7_SETUP_STAGED_MARKER,
OSC7_PTY_SHELL_MARKER,
OSC7_PTY_ACK_MARKER,
OSC7_PTY_DONE_MARKER,
OSC7_PTY_FAILED_MARKER,
buildOsc7PtyShellProbeCommand,
buildOsc7PtySetupCommands,
buildOsc7ReloadCommand,
buildOsc7SetupCommand,
buildOsc7SetupExecCommand,
@@ -30,6 +43,8 @@ import {
parseOsc7SetupStagedPath,
runOsc7SetupAction,
shouldOfferOsc7SetupAction,
waitForOsc7SessionOutput,
type RunOsc7SetupActionOptions,
} from "./osc7Setup";
const runSetup = (env: NodeJS.ProcessEnv) => {
@@ -193,6 +208,229 @@ test("runOsc7SetupAction stages the script and types a short runner for user-swi
assert.deepEqual(localData, []);
});
// Model separate exec/PTY identities (e.g. Koko) with live output observers.
// Echo input first, then deliver acknowledgements in separate chunks.
const createExit4PtyHarness = (shell: string | null = "bash", completion: "done" | "failed" | null = "done") => {
const writes: string[] = [];
const setupCommands: string[] = [];
const timeouts: number[] = [];
let onData: ((chunk: string) => void) | undefined;
let disposed = 0;
let ready = true;
const options: RunOsc7SetupActionOptions = {
status: "connected",
sessionId: "pty-session",
setupCommand: "exec-only-setup",
setupOsc7Tracking: async (_id, command) => {
setupCommands.push(command);
return { success: false, code: 4, error: "exec identity mismatch" };
},
isTerminalReadyForCommandInjection: () => ready,
waitForSessionOutput: (id, predicate, timeout) => {
timeouts.push(timeout);
return waitForOsc7SessionOutput((observedId, callback) => {
assert.equal(observedId, options.sessionId);
onData = callback;
return () => { onData = undefined; disposed += 1; };
}, id, predicate, 10);
},
writeToSession: (id, command, writeOptions) => {
assert.equal(id, options.sessionId);
assert.equal(writeOptions?.automated, true);
assert.deepEqual(writeOptions?.logRewrite, { sentCommand: command, displayCommand: "" });
assert.ok(onData, "subscribe before writing so fast replies cannot be lost");
writes.push(command);
onData(`prompt> ${command.replace(/\r$/, "\r\n")}`);
assert.ok(onData, "input echo must not satisfy the output predicate");
const final = command.includes("base64 -d");
const marker = writes.length === 1 ? OSC7_PTY_SHELL_MARKER
: !final ? OSC7_PTY_ACK_MARKER
: completion === "failed" ? OSC7_PTY_FAILED_MARKER : OSC7_PTY_DONE_MARKER;
const value = writes.length === 1 ? shell : !final ? String(writes.length - 2) : completion && shell;
if (value) {
onData(`\r\n${marker.slice(0, 12)}`);
onData?.(`${marker.slice(12)}${value}\r`);
assert.ok(onData, "wait for the complete line, not a partial shell name");
onData?.("\n");
}
},
};
return {
options, writes, setupCommands, timeouts,
setReady: (value: boolean) => { ready = value; },
disposed: () => disposed,
};
};
test("exit 4 probes the foreground shell and waits for chunked installation DONE, without exec staging", async () => {
for (const shell of ["bash", "zsh"]) {
const h = createExit4PtyHarness(shell);
const result = await runOsc7SetupAction(h.options);
assert.equal(result.success, true);
assert.equal(result.sentToTerminal, true);
assert.equal(result.error, undefined);
assert.deepEqual(h.setupCommands, ["exec-only-setup"]);
assert.deepEqual(h.writes, [buildOsc7PtyShellProbeCommand(), ...buildOsc7PtySetupCommands(shell as "bash" | "zsh")]);
assert.match(h.writes[0], /ZSH_VERSION.*BASH_VERSION/);
assert.match(h.writes[1], /mktemp/);
assert.match(h.writes.at(-1)!, new RegExp(`NETCATTY_OSC7_FORCE_SHELL=${shell}`));
for (const command of h.writes) assert.ok(Buffer.byteLength(command) < 3000);
assert.deepEqual(h.timeouts, h.writes.map((_, i) => i === h.writes.length - 1 ? 20000 : 5000));
assert.equal(h.disposed(), h.writes.length);
}
});
test("exit-4 stderr messages also select the dedicated PTY path", async () => {
for (const stderr of [
"Netcatty OSC 7 setup: could not identify the active terminal shell",
"Netcatty OSC 7 setup: active terminal shell did not match the current tab",
]) {
const h = createExit4PtyHarness();
h.options.setupOsc7Tracking = async () => ({ success: false, stderr: `${stderr}\n` });
assert.equal((await runOsc7SetupAction(h.options)).success, true);
assert.equal(h.writes.length, 1 + buildOsc7PtySetupCommands("bash").length);
}
});
test("exit 4 fails closed with an unsafe prompt or missing safety callbacks", async () => {
for (const missing of ["unsafe", "isTerminalReadyForCommandInjection", "waitForSessionOutput"] as const) {
const h = createExit4PtyHarness();
if (missing === "unsafe") h.setReady(false);
else delete h.options[missing];
const result = await runOsc7SetupAction(h.options);
assert.equal(result.success, false);
assert.match(result.error || "", /idle bash\/zsh prompt.*empty input/);
assert.deepEqual(h.writes, []);
assert.deepEqual(h.timeouts, []);
}
});
test("exit 4 blocks recalled input, editors, alt-screen and sensitive prompts with an empty command buffer", async () => {
for (const unsafe of [
{ userInputLength: 12 }, // ESC-prefixed history recall need not enter commandBufferRef.
{ isAtPrompt: false },
{ alternateScreenActive: true },
{ sensitiveInputActive: true },
{ hasLiveTerminal: false },
]) {
const h = createExit4PtyHarness();
const unregister = registerTerminalCommandInjectionReadyReader(h.options.sessionId, () =>
isIdleShellReadyForCommandInjection({
hasLiveTerminal: true, isAtPrompt: true, userInputLength: 0,
pendingTypedInputLength: 0, alternateScreenActive: false,
sensitiveInputActive: false, ...unsafe,
}));
h.options.isTerminalReadyForCommandInjection = () => isTerminalReadyForCommandInjection(h.options.sessionId);
try {
assert.equal((await runOsc7SetupAction(h.options)).success, false);
assert.deepEqual(h.writes, []);
} finally {
unregister();
}
assert.equal(isTerminalReadyForCommandInjection(h.options.sessionId), false);
}
});
test("Terminal wires the live command-injection readiness registry into OSC7 setup", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
assert.match(source, /runOsc7SetupAction\(\{[^]*?isTerminalReadyForCommandInjection: \(\) => isTerminalReadyForCommandInjection\(sessionId\)/);
});
test("exit 4 refuses installation after probe timeout, unknown shell, or unsupported fish", async () => {
for (const shell of [null, "unknown", "fish"]) {
const h = createExit4PtyHarness(shell);
const result = await runOsc7SetupAction(h.options);
assert.equal(result.success, false);
assert.match(result.error || "", /idle bash\/zsh prompt.*OSC 7 manually/);
assert.equal(h.writes.length, 1);
assert.equal(h.disposed(), 1);
}
});
test("exit 4 rechecks prompt readiness after the probe and between payload chunks", async () => {
for (const stopAfter of [1, 3]) {
const h = createExit4PtyHarness();
const write = h.options.writeToSession;
h.options.writeToSession = (...args) => {
write(...args);
if (h.writes.length === stopAfter) h.setReady(false);
};
const result = await runOsc7SetupAction(h.options);
assert.equal(result.success, false);
assert.equal(h.writes.length, stopAfter);
}
});
test("exit 4 does not claim success when installation times out or reports failure", async () => {
for (const completion of [null, "failed"] as const) {
const h = createExit4PtyHarness("bash", completion);
const result = await runOsc7SetupAction(h.options);
assert.equal(result.success, false);
assert.notEqual(result.sentToTerminal, true);
assert.match(result.error || "", /failed or did not finish/);
assert.equal(h.writes.length, 1 + buildOsc7PtySetupCommands("bash").length);
assert.equal(h.disposed(), h.writes.length);
}
});
test("exit 4 stops on a missing, stale or failed chunk acknowledgement", async () => {
for (const reply of [null, `${OSC7_PTY_ACK_MARKER}0`, `${OSC7_PTY_FAILED_MARKER}bash`]) {
const h = createExit4PtyHarness();
let count = 0;
let observer: ((chunk: string) => void) | undefined;
h.options.waitForSessionOutput = (id, predicate) => waitForOsc7SessionOutput((_id, callback) => {
observer = callback;
return () => { observer = undefined; };
}, id, predicate, 10);
h.options.writeToSession = (id, command, options) => {
count += 1;
if (count === 3) {
observer?.(`prompt> ${command}\r\n`);
if (reply) observer?.(`\r\n${reply}\r\n`);
} else {
observer?.(`prompt> ${command}\r\n`);
observer?.(`\r\n${count === 1 ? OSC7_PTY_SHELL_MARKER + "bash" : OSC7_PTY_ACK_MARKER + "0"}\r\n`);
}
assert.equal(id, h.options.sessionId);
assert.equal(options?.automated, true);
};
const result = await runOsc7SetupAction(h.options);
assert.equal(result.success, false);
assert.equal(count, 3, "no later chunk or final installer after a failed ack");
assert.match(result.error || "", /failed or did not finish/);
}
});
test("unrelated exec failures never select the exit-4 PTY transport", async () => {
for (const code of [1, 2, 3, 5]) {
const h = createExit4PtyHarness();
h.options.setupOsc7Tracking = async () => ({ success: false, code, stderr: "unrelated failure" });
const result = await runOsc7SetupAction(h.options);
assert.equal(result.success, false);
assert.deepEqual(h.writes, []);
assert.deepEqual(h.timeouts, []);
}
});
test("waitForOsc7SessionOutput disposes even if subscription immediately delivers output", async () => {
let disposed = false;
const result = await waitForOsc7SessionOutput((_id, callback) => {
callback("done");
return () => { disposed = true; };
}, "session", (output) => output === "done", 100);
assert.equal(result, "done");
assert.equal(disposed, true);
});
test("exit 4 sends no probe when the output subscription is unavailable", async () => {
const h = createExit4PtyHarness();
h.options.waitForSessionOutput = (id, predicate, timeout) => waitForOsc7SessionOutput(() => {
throw new Error("Session output unavailable");
}, id, predicate, timeout);
await assert.rejects(runOsc7SetupAction(h.options), /Session output unavailable/);
assert.deepEqual(h.writes, []);
});
test("runOsc7SetupAction fails when the setup script cannot be staged", async () => {
const writes: string[] = [];
let calls = 0;
@@ -776,6 +1014,164 @@ const withStagedSetupScript = (fn: (scriptPath: string) => void) => {
}
};
test("PTY builders chunk the exact shared script into lines below the canonical limit", () => {
for (const shell of ["bash", "zsh"] as const) {
const commands = buildOsc7PtySetupCommands(shell);
assert.ok(commands.length > 3);
for (const command of commands) {
assert.ok(command.startsWith(" "));
assert.ok(command.endsWith("\r"));
assert.doesNotMatch(command.slice(0, -1), /[\r\n]/);
assert.ok(Buffer.byteLength(command) < 3000);
}
assert.match(commands[0], /mktemp/);
assert.match(commands.at(-1)!, new RegExp(`NETCATTY_OSC7_FORCE_SHELL=${shell}`));
assert.doesNotMatch(commands.join(""), /NETCATTY_OSC7_EXPECTED_CWD/);
const payload = commands.flatMap((command) => {
const match = command.match(/printf '%s' '([A-Za-z0-9+/=]+)'/);
return match ? [match[1]] : [];
}).join("");
const script = Buffer.from(payload, "base64").toString("utf8");
assert.ok(buildOsc7SetupExecCommand().includes(quoteShellArg(script)), "reuse exact POSIX setup script");
assert.match(script, /netcatty-osc7-version: 2/);
}
});
test("exit-4 transport installs through a real canonical Bash PTY without exceeding its line limit", { timeout: 30000 }, async () => {
const home = mkdtempSync(join(tmpdir(), "netcatty-osc7-canonical-"));
const prompt = "__NETCATTY_TEST_PROMPT__> ";
const terminal = pty.spawn("/bin/bash", ["--noprofile", "--norc", "--noediting", "-i"], {
name: "xterm", cols: 120, rows: 24,
env: { ...process.env, HOME: home, SHELL: "/bin/unknown", PS1: prompt, PS2: "CONTINUATION> ", PROMPT_COMMAND: "", ZDOTDIR: "", XDG_CONFIG_HOME: "" },
});
let screen = "";
let transcript = "";
const capture = terminal.onData((chunk) => { screen += chunk; transcript += chunk; });
const wait = (predicate: (output: string) => boolean, timeout = 5000) =>
waitForOsc7SessionOutput((_id, callback) => {
const listener = terminal.onData(callback);
return () => listener.dispose();
}, "real-pty", predicate, timeout);
try {
assert.ok(await wait((output) => output.endsWith(prompt)), "initial Bash prompt");
// --noediting keeps Bash on the kernel's canonical input path, unlike Readline.
const canonical = wait((output) => output.endsWith(prompt));
screen = "";
terminal.write("stty icanon; stty -a\r");
assert.match(await canonical || "", /(?:^|[;\s])icanon(?:[;\s]|$)/);
const writes: string[] = [];
const result = await runOsc7SetupAction({
status: "connected", sessionId: "real-pty", setupCommand: "exec-only-setup",
setupOsc7Tracking: async () => ({ success: false, code: 4 }),
isTerminalReadyForCommandInjection: () => screen.endsWith(prompt),
waitForSessionOutput: (_id, predicate, timeout) => wait(predicate, timeout),
writeToSession: (_id, command) => {
assert.ok(Buffer.byteLength(command) < 3000, "each typed line fits canonical input");
writes.push(command);
screen = "";
// Preserve the path for a shell-side cleanup assertion after the final command.
terminal.write(command.includes("base64 -d")
? ` __netcatty_test_file="$__netcatty_osc7_pty_file"; ${command}` : command);
},
});
assert.equal(result.success, true, `${result.error}\n${transcript.slice(-4000)}`);
assert.ok(writes.length > 3);
assert.ok(writes.reduce((total, command) => total + command.length, 0) > 4096);
assert.equal(markerCount(readFileSync(join(home, ".bashrc"), "utf8")), 2);
assert.ok(transcript.includes("\u001b]7;file://"));
assert.match(transcript, new RegExp(`\r?\n${OSC7_PTY_DONE_MARKER}bash\r?\n`));
assert.doesNotMatch(transcript, /CONTINUATION> /);
// A final shell assertion covers cleanup without exec sharing any staging file.
if (!screen.endsWith(prompt)) assert.ok(await wait((output) => output.endsWith(prompt)));
const cleaned = wait((output) => output.includes("\r\nCLEANED\r\n"));
terminal.write("test -n \"$__netcatty_test_file\" && test ! -e \"$__netcatty_test_file\" && test -z \"${__netcatty_osc7_pty_file-}\" && printf '\\n%s\\n' CLEANED\r");
assert.ok(await cleaned, "transport removes the PTY-local file and unsets its path");
} finally {
capture.dispose();
terminal.kill();
rmSync(home, { recursive: true, force: true });
}
});
test("PTY probe recognizes interactive bash and rejects a noninteractive shell", () => {
for (const interactive of [true, false]) {
const args = ["--noprofile", "--norc", ...(interactive ? ["-i"] : []), "-c",
buildOsc7PtyShellProbeCommand().replace(/\r$/, "\n")];
const output = execFileSync("/bin/bash", args, { stdio: "pipe" }).toString("utf8");
assert.match(output, new RegExp(`\n${OSC7_PTY_SHELL_MARKER}${interactive ? "bash" : "unknown"}\n`));
}
});
test("chunked PTY installer runs locally in bash, preserves hooks and stays idempotent with DONE ack", () => {
withTempHome("netcatty-osc7-inline-bash-", (home) => {
const rcPath = join(home, ".bashrc");
writeFileSync(rcPath, 'PROMPT_COMMAND="printf existing-title"\n');
const command = buildOsc7PtySetupCommands("bash").join("").replace(/\r/g, "\n");
for (let run = 0; run < 2; run += 1) {
const output = execFileSync("/bin/bash", ["--noprofile", "--norc", "-i", "-c", command], {
env: { ...process.env, HOME: home, SHELL: "/bin/unknown", ZDOTDIR: "", XDG_CONFIG_HOME: "" },
stdio: "pipe",
}).toString("utf8");
assert.match(output, new RegExp(`\n${OSC7_PTY_DONE_MARKER}bash\n`));
assert.doesNotMatch(output, /__NETCATTY_OSC7_PTY_FAILED__/);
assert.ok(output.includes("\u001b]7;file://"));
const rc = readFileSync(rcPath, "utf8");
assert.equal(markerCount(rc), 2);
assert.match(rc, /netcatty-osc7-version: 2/);
assert.match(rc, /printf existing-title/);
}
});
});
test("chunked PTY installer emits failure instead of DONE when install or reload fails", () => {
withTempHome("netcatty-osc7-inline-fail-", (home) => {
const blockedHome = join(home, "not-a-directory");
writeFileSync(blockedHome, "file");
writeFileSync(join(home, ".bashrc"), "return 1\n");
for (const targetHome of [blockedHome, home]) {
const output = execFileSync("/bin/bash", ["--noprofile", "--norc", "-c",
buildOsc7PtySetupCommands("bash").join("").replace(/\r/g, "\n")], {
env: { ...process.env, HOME: targetHome, ZDOTDIR: "", XDG_CONFIG_HOME: "" },
stdio: "pipe",
}).toString("utf8");
assert.match(output, new RegExp(`\n${OSC7_PTY_FAILED_MARKER}bash\n`));
assert.doesNotMatch(output, /__NETCATTY_OSC7_PTY_DONE__/);
}
});
});
test("PTY probe and chunked installer remove their bash history entries", () => {
withTempHome("netcatty-osc7-inline-history-", (home) => {
const output = runInteractiveHistoryProbe({
shellPath: "/bin/bash",
shellArgs: ["--noprofile", "--norc", "-i"],
dumpHistoryCommand: `history > ${quoteShellArg(join(home, "history"))}`,
dumpPath: join(home, "history"),
env: { HOME: home, HISTFILE: join(home, ".bash_history"), SHELL: "/bin/bash" },
input: `echo keepme\n${buildOsc7PtyShellProbeCommand()}${buildOsc7PtySetupCommands("bash").join("")}`,
});
assert.match(output, /echo keepme/);
assert.doesNotMatch(output, /NETCATTY_OSC7|base64|history -d/);
});
});
test("PTY probe and chunked installer support zsh with shell-local ZDOTDIR", (t) => {
const shellPath = existingShells(["/bin/zsh", "/usr/bin/zsh"])[0];
if (!shellPath) return t.skip("zsh is not installed on this runner");
withTempHome("netcatty-osc7-inline-zsh-", (home) => {
const zdotdir = join(home, "custom-zsh");
const command = `ZDOTDIR=${quoteShellArg(zdotdir)}; ${buildOsc7PtyShellProbeCommand()}${buildOsc7PtySetupCommands("zsh").join("")}`;
const output = execFileSync(shellPath, ["-f", "-i", "-c", command.replace(/\r/g, "\n")], {
env: { ...process.env, HOME: home, SHELL: "/bin/unknown", ZDOTDIR: undefined },
stdio: "pipe",
}).toString("utf8");
assert.match(output, new RegExp(`\n${OSC7_PTY_SHELL_MARKER}zsh\n`));
assert.match(output, new RegExp(`\n${OSC7_PTY_DONE_MARKER}zsh\n`));
assert.equal(markerCount(readFileSync(join(zdotdir, ".zshrc"), "utf8")), 2);
assert.equal(existsSync(join(home, ".zshrc")), false);
});
});
test("buildOsc7TypedSetupCommand stays a single line for reliable history cleanup", async () => {
const sha256 = await getOsc7StagedScriptSha256();
for (const shell of ["bash", "zsh", "fish"] as const) {
+159 -1
View File
@@ -36,7 +36,7 @@ export type Osc7SetupRunResult = {
code?: number | null;
error?: string;
reloadCommand?: string;
/** Setup was retyped into the terminal for a user-switched (su/sudo) shell. */
/** Setup was sent through the foreground terminal. */
sentToTerminal?: boolean;
};
@@ -57,6 +57,14 @@ export type RunOsc7SetupActionOptions = {
},
) => void;
writeLocalTerminalData?: (data: string) => void;
/** Live prompt safety gate (drafts, sensitive prompts and alt-screen block). */
isTerminalReadyForCommandInjection?: () => boolean;
/** Subscribe synchronously (throw if unavailable); resolve null on timeout. */
waitForSessionOutput?: (
sessionId: string,
predicate: (output: string) => boolean,
timeoutMs: number,
) => Promise<string | null>;
};
export const shouldOfferOsc7SetupAction = ({
@@ -675,6 +683,89 @@ export const buildOsc7TypedSetupCommand = (
return ` ${run}; source (test -n "${DOLLAR}XDG_CONFIG_HOME"; and echo "${DOLLAR}XDG_CONFIG_HOME"; or echo "${DOLLAR}HOME/.config")/fish/config.fish >/dev/null 2>&1; __netcatty_osc7_cwd 2>/dev/null; true\r`;
};
export const OSC7_PTY_SHELL_MARKER = "__NETCATTY_OSC7_PTY_SHELL__=";
export const OSC7_PTY_ACK_MARKER = "__NETCATTY_OSC7_PTY_ACK__=";
export const OSC7_PTY_DONE_MARKER = "__NETCATTY_OSC7_PTY_DONE__=";
export const OSC7_PTY_FAILED_MARKER = "__NETCATTY_OSC7_PTY_FAILED__=";
type Osc7PtyShell = "bash" | "zsh";
// Split marker and value in printf so terminal input echo cannot acknowledge
// the probe/install. A leading newline separates markers from prompt/OSC data.
const ptyMarkerCommand = (marker: string, value: string): string =>
`printf '\\n%s%s\\n' '${marker}' ${value}`;
export const buildOsc7PtyShellProbeCommand = (): string =>
` case "$-" in *i*) if test -n "${DOLLAR}{ZSH_VERSION-}"; then ${ptyMarkerCommand(OSC7_PTY_SHELL_MARKER, "zsh")}; elif test -n "${DOLLAR}{BASH_VERSION-}"; then ${ptyMarkerCommand(OSC7_PTY_SHELL_MARKER, "bash")}; else ${ptyMarkerCommand(OSC7_PTY_SHELL_MARKER, "unknown")}; fi;; *) ${ptyMarkerCommand(OSC7_PTY_SHELL_MARKER, "unknown")};; esac; ${BASH_DELETE_MARKED_HISTORY_COMMAND}\r`;
/** PTY-local transport. Every line stays below Linux's canonical input limit. */
export const buildOsc7PtySetupCommands = (shell: Osc7PtyShell): string[] => {
const cleanup = shell === "bash" ? `; ${BASH_DELETE_MARKED_HISTORY_COMMAND}` : "";
const finish = (command: string): string => ` ${command}${cleanup}\r`;
const failed = ptyMarkerCommand(OSC7_PTY_FAILED_MARKER, shell);
const file = '"$__netcatty_osc7_pty_file"';
const commands = [finish(
`if __netcatty_osc7_pty_file=$(mktemp /tmp/.netcatty-osc7-pty.XXXXXX); then ${ptyMarkerCommand(OSC7_PTY_ACK_MARKER, "0")}; else ${failed}; fi`,
)];
// Leave room for the append, ack, and Bash history cleanup on each line.
const payload = btoa(POSIX_SETUP_SCRIPT);
const chunkSize = 1800;
for (let offset = 0; offset < payload.length; offset += chunkSize) {
const ack = String(commands.length);
commands.push(finish(
`if test -n ${file} && printf '%s' '${payload.slice(offset, offset + chunkSize)}' >> ${file}; then ${ptyMarkerCommand(OSC7_PTY_ACK_MARKER, ack)}; else rm -f -- ${file}; unset __netcatty_osc7_pty_file; ${failed}; fi`,
));
}
const runner = 'c=$(base64 -d "$1") && printf "%s\\n" "$c" | sh';
const shellEnv = shell === "zsh" ? ' NETCATTY_ZDOTDIR="${ZDOTDIR:-}"' : "";
const run = `env NETCATTY_OSC7_FORCE_SHELL=${shell}${shellEnv} sh -c ${quoteForSingleQuotedShellString(runner)} sh ${file}`;
const rc = shell === "bash" ? '$HOME/.bashrc' : '${ZDOTDIR:-$HOME}/.zshrc';
commands.push(finish(
`if ${run} && . "${rc}" >/dev/null 2>&1 && osc7_cwd 2>/dev/null; then __netcatty_osc7_pty_result='${OSC7_PTY_DONE_MARKER}'; else __netcatty_osc7_pty_result='${OSC7_PTY_FAILED_MARKER}'; fi; rm -f -- ${file}; unset __netcatty_osc7_pty_file; printf '\\n%s%s\\n' "$__netcatty_osc7_pty_result" ${shell}; unset __netcatty_osc7_pty_result`,
));
return commands;
};
// Only accept complete output lines, including when a marker spans chunks.
const readPtyMarker = (output: string, marker: string): string | null =>
readMarkerLine(output.slice(0, output.lastIndexOf("\n") + 1), marker);
/** Short-lived observer; never consume/replay terminal backlog or flow acks. */
export const waitForOsc7SessionOutput = (
subscribe: (sessionId: string, onData: (chunk: string) => void) => () => void,
sessionId: string,
predicate: (output: string) => boolean,
timeoutMs: number,
): Promise<string | null> => {
let output = "";
let dispose: (() => void) | undefined;
let settled = false;
let result: string | null = null;
let resolveOutput: ((value: string | null) => void) | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
const finish = (value: string | null) => {
settled = true;
result = value;
clearTimeout(timer);
dispose?.();
resolveOutput?.(value);
};
// Let subscription errors throw before runOsc7SetupAction sends any input.
dispose = subscribe(sessionId, (chunk) => {
if (settled) return;
output = (output + chunk).slice(-65536);
if (predicate(output)) finish(output);
});
if (settled) {
dispose();
return Promise.resolve(result);
}
return new Promise((resolve) => {
resolveOutput = resolve;
timer = setTimeout(() => finish(null), timeoutMs);
});
};
const isOsc7SetupShell = (value: string): value is Osc7SetupShell =>
value === "bash" || value === "zsh" || value === "fish";
@@ -744,6 +835,8 @@ export const runOsc7SetupAction = async ({
setupOsc7Tracking,
writeToSession,
writeLocalTerminalData,
isTerminalReadyForCommandInjection,
waitForSessionOutput,
}: RunOsc7SetupActionOptions): Promise<Osc7SetupRunResult> => {
if (status !== "connected") {
return { success: false, error: "Terminal is not connected" };
@@ -788,6 +881,71 @@ export const runOsc7SetupAction = async ({
return { ...result, success: true, sentToTerminal: true };
}
// Only this confirmed action may use foreground setup on exec identity
// failures (#4052). Keep the OTHER_USER staged transport above unchanged.
const identityErrors = [
"Netcatty OSC 7 setup: could not identify the active terminal shell",
"Netcatty OSC 7 setup: active terminal shell did not match the current tab",
];
if (result.code === 4 || identityErrors.includes(result.stderr?.trim() || "")) {
const idleError = "Configure directory tracking needs an idle bash/zsh prompt with empty input. Clear pending input and try again, or configure OSC 7 manually.";
if (!waitForSessionOutput || !isTerminalReadyForCommandInjection || !isTerminalReadyForCommandInjection()) {
return { ...result, success: false, error: idleError };
}
// Output observers run before xterm has necessarily rendered the next prompt.
// Wait briefly for the live safety reader; never infer readiness from an ack.
const waitUntilPromptReady = async (): Promise<boolean> => {
const deadline = Date.now() + 5000;
while (!isTerminalReadyForCommandInjection()) {
if (Date.now() >= deadline) return false;
await new Promise((resolve) => setTimeout(resolve, 25));
}
return true;
};
const probeOutput = waitForSessionOutput(
sessionId,
(output) => readPtyMarker(output, OSC7_PTY_SHELL_MARKER) !== null,
5000,
);
const probe = buildOsc7PtyShellProbeCommand();
writeToSession(sessionId, probe, {
automated: true,
logRewrite: { sentCommand: probe, displayCommand: "" },
});
const shell = readPtyMarker(await probeOutput || "", OSC7_PTY_SHELL_MARKER);
if ((shell !== "bash" && shell !== "zsh") || !await waitUntilPromptReady()) {
return { ...result, success: false, error: idleError };
}
const completed = (output: string) => readPtyMarker(output, OSC7_PTY_DONE_MARKER) === shell;
const failed = (output: string) => readPtyMarker(output, OSC7_PTY_FAILED_MARKER) === shell;
const commands = buildOsc7PtySetupCommands(shell);
for (let index = 0; index < commands.length; index += 1) {
if (!await waitUntilPromptReady()) {
return { ...result, success: false, error: idleError };
}
const final = index === commands.length - 1;
const acknowledged = (output: string) => final
? completed(output) : readPtyMarker(output, OSC7_PTY_ACK_MARKER) === String(index);
// Subscribe before every write, and wait for this command's acknowledgement
// before typing another line (including when the shell uses canonical input).
const installOutput = waitForSessionOutput(sessionId, (output) => acknowledged(output) || failed(output), final ? 20000 : 5000);
const command = commands[index];
writeToSession(sessionId, command, {
automated: true,
logRewrite: { sentCommand: command, displayCommand: "" },
});
const output = await installOutput;
if (!output || failed(output) || !acknowledged(output)) {
return {
...result,
success: false,
error: "Foreground directory tracking setup failed or did not finish within 20 seconds. Check the terminal; manual OSC 7 configuration is also available.",
};
}
}
return { ...result, success: true, sentToTerminal: true, error: undefined };
}
if (!result.success || (typeof result.code === "number" && result.code !== 0)) {
return {
...result,