Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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, `command rm -f -- ${shellQuote(path)} || /bin/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 },
])("removes the consumed script through PATH 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; cleanup must still invoke the PATH executable.
expect(execFileSync("/bin/sh", ["-c", `rm() { return 97; }; ${invocation}`], {
encoding: "utf8", env: { ...process.env, PATH: bin },
})).toBe("launched");
expect(existsSync(marker)).toBe(true);
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(`command rm -f -- '/tmp/launch '\"'\"'quoted'\"'\"'.sh' || /bin/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(`command rm -f -- ${shellQuote(f.scriptPath)} || /bin/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" });
`command rm -f -- '/tmp/launch '\"'\"'quoted'\"'\"'.sh' || /bin/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(`command rm -f -- '/tmp/launch '\"'\"'quoted'\"'\"'.sh' || /bin/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