Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/daemon/src/adapters/tmux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,7 @@ export class TmuxAdapter {
return this.guardedInput(target, () => this.sendShellCommandUnchecked(target, command, beforeInput, options));
}

/** Stage or send a launch while leaving a consumed script's cleanup to the receiving shell. */
private async sendShellCommandUnchecked(target: string, command: string, beforeInput: (() => void) | undefined, options: TmuxShellCommandOptions): Promise<TmuxResult> {
const commandBytes = Buffer.byteLength(command, "utf8");
// POSIX shells retain subshell isolation; unknown/unreadable panes use sh.
Expand Down Expand Up @@ -758,7 +759,7 @@ export class TmuxAdapter {
let created = false;
try {
if (path) {
await this.fileOps.writeFile(path, `/bin/rm -f -- ${shellQuote(path)}\n${options.execInScript ? "exec " : ""}${command}\n`, { mode: 0o600, flag: "wx" });
await this.fileOps.writeFile(path, `/bin/rm -f -- ${shellQuote(path)} 2>/dev/null || command rm -f -- ${shellQuote(path)} || true\n${options.execInScript ? "exec " : ""}${command}\n`, { mode: 0o600, flag: "wx" });
created = true;
}
const text = beforeInput ? await this.guardedInput(target, (pane, check) => this.sendTextUnchecked(pane, invocation, () => { check(); beforeInput(); }))
Expand Down
58 changes: 53 additions & 5 deletions packages/daemon/test/tmux-shell-command.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { describe, it, expect, vi } from "vitest";
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { shellQuote } from "../src/adapters/shell-quote.js";
import { TmuxAdapter, type TmuxFileOps } from "../src/adapters/tmux.js";

/** Capture staged scripts and tmux requests without writing into a live pane. */
function fixture(fail?: string, scriptPath = "/tmp/launch 'quoted'.sh") {
const files = new Map<string, string>();
let names = 0;
Expand All @@ -26,6 +27,53 @@ function fixture(fail?: string, scriptPath = "/tmp/launch 'quoted'.sh") {
}

describe("shell launch transport", () => {
it.each(["missing", "nonzero"])("keeps sourced launch alive with errexit when PATH rm is %s", async failure => {
const root = mkdtempSync(join(tmpdir(), "launch-cleanup-failure-"));
try {
const bin = join(root, "bin");
mkdirSync(bin);
if (failure === "nonzero") writeFileSync(join(bin, "rm"), "#!/bin/sh\nexit 72\n", { mode: 0o755 });
const f = fixture(undefined, join(root, "launch 'quoted'.sh"));
expect(await f.adapter.sendShellCommand("pane", "/bin/sh -c 'printf launched'", undefined, { sourceInPane: true })).toEqual({ ok: true });
writeFileSync(f.scriptPath, f.files.get(f.scriptPath)!, { mode: 0o600 });
const invocation = vi.mocked(f.fileOps.writeFile).mock.calls[1]![1];
expect(execFileSync("/bin/sh", ["-ec", invocation], {
encoding: "utf8", env: { ...process.env, PATH: bin },
})).toBe("launched");
// Systems with /bin/rm retain the previous cleanup route; otherwise the payload still runs.
expect(existsSync(f.scriptPath)).toBe(!existsSync("/bin/rm"));
} finally { rmSync(root, { recursive: true, force: true }); }
});

it.each([
{ sourceInPane: false, execInScript: false },
{ sourceInPane: true, execInScript: false },
{ sourceInPane: false, execInScript: true },
])("prefers absolute cleanup and uses PATH when unavailable before launching (%j)", async options => {
const root = mkdtempSync(join(tmpdir(), "launch-cleanup-"));
try {
const bin = join(root, "bin");
const marker = join(root, "rm-arguments");
const retained = join(root, "retained.txt");
mkdirSync(bin);
writeFileSync(retained, "keep me");
const realRm = execFileSync("/bin/sh", ["-c", "command -v rm"], { encoding: "utf8" }).trim();
writeFileSync(join(bin, "rm"), `#!/bin/sh\nprintf '%s\\n' "$@" > ${shellQuote(marker)}\nexec ${shellQuote(realRm)} "$@"\n`, { mode: 0o755 });
const f = fixture(undefined, join(root, "launch 'quoted'.sh"));
expect(await f.adapter.sendShellCommand("pane", "/bin/sh -c 'printf launched'", undefined, options)).toEqual({ ok: true });
writeFileSync(f.scriptPath, f.files.get(f.scriptPath)!, { mode: 0o600 });
const invocation = vi.mocked(f.fileOps.writeFile).mock.calls[1]![1];
// A sourced script inherits functions; the PATH fallback must bypass them.
expect(execFileSync("/bin/sh", ["-c", `rm() { return 97; }; ${invocation}`], {
encoding: "utf8", env: { ...process.env, PATH: bin },
})).toBe("launched");
expect(existsSync(marker)).toBe(!existsSync("/bin/rm"));
if (!existsSync("/bin/rm")) expect(readFileSync(marker, "utf8")).toBe(`-f\n--\n${f.scriptPath}\n`);
expect(existsSync(f.scriptPath)).toBe(false);
expect(readFileSync(retained, "utf8")).toBe("keep me");
} finally { rmSync(root, { recursive: true, force: true }); }
});

it.each(["fish", "-fish"])("selects fish sourcing or the older-fish sh fallback before launching (%s)", async shell => {
const f = fixture();
vi.spyOn(f.adapter, "getPaneCommand").mockResolvedValue(shell);
Expand All @@ -35,7 +83,7 @@ describe("shell launch transport", () => {
const quotedPath = shellQuote(f.scriptPath);
expect(invocation).toBe(`if eval 'OPENRIG_FISH_ASSIGNMENT_PROBE=1 /bin/sh -c :'; source ${quotedPath}; else; /bin/sh ${quotedPath}; end`);
expect(Buffer.byteLength(invocation)).toBeLessThan(512);
expect(f.files.get(f.scriptPath)).toBe(`/bin/rm -f -- '/tmp/launch '\"'\"'quoted'\"'\"'.sh'\n${command}\n`);
expect(f.files.get(f.scriptPath)).toBe(`/bin/rm -f -- '/tmp/launch '\"'\"'quoted'\"'\"'.sh' 2>/dev/null || command rm -f -- '/tmp/launch '\"'\"'quoted'\"'\"'.sh' || true\n${command}\n`);
expect(f.commands.at(-1)).toBe("tmux send-keys -t 'pane' 'Enter'");
});

Expand Down Expand Up @@ -82,7 +130,7 @@ describe("shell launch transport", () => {
vi.spyOn(f.adapter, "getPaneCommand").mockResolvedValue("fish");
expect(await f.adapter.sendShellCommand("pane", "claude", undefined, { sourceInPane: true })).toEqual({ ok: true });
expect(vi.mocked(f.fileOps.writeFile).mock.calls[1]![1]).toBe(`/bin/sh ${shellQuote(f.scriptPath)}`);
expect(f.files.get(f.scriptPath)).toBe(`/bin/rm -f -- ${shellQuote(f.scriptPath)}\nclaude\n`);
expect(f.files.get(f.scriptPath)).toBe(`/bin/rm -f -- ${shellQuote(f.scriptPath)} 2>/dev/null || command rm -f -- ${shellQuote(f.scriptPath)} || true\nclaude\n`);
});

it("keeps the short sh invocation when fish fallback syntax would exceed the input bound", async () => {
Expand Down Expand Up @@ -127,7 +175,7 @@ describe("shell launch transport", () => {
const command = `env PATH='${"p".repeat(4096)}' codex -s workspace-write resume 'same-native-id' -m 'chosen-model'`;
expect(await f.adapter.sendShellCommand("pane", command)).toEqual({ ok: true });
expect(f.fileOps.writeFile).toHaveBeenNthCalledWith(1, f.scriptPath,
`/bin/rm -f -- '/tmp/launch '\"'\"'quoted'\"'\"'.sh'\n${command}\n`, { mode: 0o600, flag: "wx" });
`/bin/rm -f -- '/tmp/launch '\"'\"'quoted'\"'\"'.sh' 2>/dev/null || command rm -f -- '/tmp/launch '\"'\"'quoted'\"'\"'.sh' || true\n${command}\n`, { mode: 0o600, flag: "wx" });
const invocation = vi.mocked(f.fileOps.writeFile).mock.calls[1]![1];
expect(Buffer.byteLength(invocation)).toBeLessThan(512);
expect(invocation).toBe(`/bin/sh '/tmp/launch '\"'\"'quoted'\"'\"'.sh'`);
Expand All @@ -143,7 +191,7 @@ describe("shell launch transport", () => {
const invocation = vi.mocked(f.fileOps.writeFile).mock.calls[1]![1];
expect(invocation).toBe(`( . '/tmp/launch '\"'\"'quoted'\"'\"'.sh' )`);
expect(Buffer.byteLength(invocation)).toBeLessThan(512);
expect(f.files.get(f.scriptPath)).toBe(`/bin/rm -f -- '/tmp/launch '\"'\"'quoted'\"'\"'.sh'\n${command}\n`);
expect(f.files.get(f.scriptPath)).toBe(`/bin/rm -f -- '/tmp/launch '\"'\"'quoted'\"'\"'.sh' 2>/dev/null || command rm -f -- '/tmp/launch '\"'\"'quoted'\"'\"'.sh' || true\n${command}\n`);
expect(f.commands.at(-1)).toBe("tmux send-keys -t 'pane' 'Enter'");
});

Expand Down
Loading