diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts index 4f474b2af..3df67ea04 100644 --- a/packages/coding-agent/src/tools/write.ts +++ b/packages/coding-agent/src/tools/write.ts @@ -70,6 +70,8 @@ export type WriteToolInput = z.infer; export interface WriteToolDetails { diagnostics?: FileDiagnosticsResult; meta?: OutputMeta; + /** Set when the file was auto-chmod'd because content begins with a `#!` shebang. */ + madeExecutable?: boolean; } /** @@ -103,6 +105,28 @@ function appendNoteToResult(result: AgentToolResult, note: str } } +/** + * If `content` begins with a `#!` shebang, ensure the file is executable. + * + * Mirrors `chmod a+x` (adds user/group/other execute bits to existing mode). + * Errors are swallowed: chmod failure (e.g. Windows ACL, read-only mount) + * MUST NOT fail an otherwise successful write. Returns whether the mode + * actually changed so the caller can surface a note. + */ +async function maybeMarkExecutableForShebang(absolutePath: string, content: string): Promise { + if (!content.startsWith("#!")) return false; + try { + const stat = await fs.stat(absolutePath); + const currentMode = stat.mode & 0o7777; + const newMode = currentMode | 0o111; + if (newMode === currentMode) return false; + await fs.chmod(absolutePath, newMode); + return true; + } catch { + return false; + } +} + // ═══════════════════════════════════════════════════════════════════════════ // Tool Class // ═══════════════════════════════════════════════════════════════════════════ @@ -772,6 +796,7 @@ export class WriteTool implements AgentTool path.join(cwd, "session.jsonl"), + getSessionSpawns: () => "*", + getArtifactsDir: () => path.join(cwd, "artifacts"), + allocateOutputArtifact: async () => ({ id: "artifact-1", path: path.join(cwd, "artifact-1.log") }), + settings: Settings.isolated(), + enableLsp: false, + }; +} + +function resultText(result: { content: { type: string; text?: string }[] }): string { + return result.content + .filter((b): b is { type: "text"; text: string } => b.type === "text" && typeof b.text === "string") + .map(b => b.text) + .join("\n"); +} + +function details(result: { details?: { madeExecutable?: boolean } }): { madeExecutable?: boolean } { + return result.details ?? {}; +} + +describe("write tool shebang chmod", () => { + let tmpDir: string; + + beforeAll(async () => { + await Settings.init({ inMemory: true }); + }); + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "write-shebang-test-")); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("marks files starting with #! as executable and flags the result", async () => { + const filePath = path.join(tmpDir, "run.sh"); + const tool = new WriteTool(createSession(tmpDir)); + + const result = await tool.execute("call-1", { + path: filePath, + content: "#!/bin/sh\necho hi\n", + }); + + const stat = await fs.stat(filePath); + // All three execute bits flipped on (chmod a+x semantics). + expect(stat.mode & 0o111).toBe(0o111); + // Notice surfaces on details, not in the model-facing text. + expect(details(result).madeExecutable).toBe(true); + expect(resultText(result)).not.toContain("executable"); + }); + + it("does not chmod files without a shebang", async () => { + const filePath = path.join(tmpDir, "data.txt"); + const tool = new WriteTool(createSession(tmpDir)); + + const result = await tool.execute("call-2", { + path: filePath, + content: "no shebang here\n", + }); + + const stat = await fs.stat(filePath); + expect(stat.mode & 0o111).toBe(0); + expect(details(result).madeExecutable).toBeUndefined(); + }); + + it("does not re-flag when file is already executable", async () => { + const filePath = path.join(tmpDir, "preexec.sh"); + await fs.writeFile(filePath, "#!/bin/sh\nold\n"); + await fs.chmod(filePath, 0o755); + + const tool = new WriteTool(createSession(tmpDir)); + const result = await tool.execute("call-3", { + path: filePath, + content: "#!/usr/bin/env python3\nprint('hi')\n", + }); + + const stat = await fs.stat(filePath); + expect(stat.mode & 0o111).toBe(0o111); + // Mode didn't change, so no flag. + expect(details(result).madeExecutable).toBeUndefined(); + }); +});