diff --git a/src/features/claude-tasks/storage.ts b/src/features/claude-tasks/storage.ts index b8916d4ec..698a9a7ca 100644 --- a/src/features/claude-tasks/storage.ts +++ b/src/features/claude-tasks/storage.ts @@ -26,6 +26,9 @@ export function resolveTaskListId(config: Partial = {}): str const envId = process.env.ULTRAWORK_TASK_LIST_ID?.trim() if (envId) return sanitizePathSegment(envId) + const claudeEnvId = process.env.CLAUDE_CODE_TASK_LIST_ID?.trim() + if (claudeEnvId) return sanitizePathSegment(claudeEnvId) + const configId = config.sisyphus?.tasks?.task_list_id?.trim() if (configId) return sanitizePathSegment(configId) diff --git a/src/features/tmux-subagent/manager.ts b/src/features/tmux-subagent/manager.ts index d5e794a4d..5bd8d6e8c 100644 --- a/src/features/tmux-subagent/manager.ts +++ b/src/features/tmux-subagent/manager.ts @@ -127,6 +127,12 @@ export class TmuxSessionManager { return false } + // NOTE: Exposed (via `as any`) for test stability checks. + // Actual polling is owned by TmuxPollingManager. + private async pollSessions(): Promise { + await (this.pollingManager as any).pollSessions() + } + async onSessionCreated(event: SessionCreatedEvent): Promise { const enabled = this.isEnabled() log("[tmux-session-manager] onSessionCreated called", { diff --git a/src/shared/git-worktree/collect-git-diff-stats.test.ts b/src/shared/git-worktree/collect-git-diff-stats.test.ts new file mode 100644 index 000000000..678d2f67a --- /dev/null +++ b/src/shared/git-worktree/collect-git-diff-stats.test.ts @@ -0,0 +1,66 @@ +/// + +import { describe, expect, mock, test } from "bun:test" + +const execSyncMock = mock(() => { + throw new Error("execSync should not be called") +}) + +const execFileSyncMock = mock((file: string, args: string[], _opts: { cwd?: string }) => { + if (file !== "git") throw new Error(`unexpected file: ${file}`) + const subcommand = args[0] + + if (subcommand === "diff") { + return "1\t2\tfile.ts\n" + } + + if (subcommand === "status") { + return " M file.ts\n" + } + + throw new Error(`unexpected args: ${args.join(" ")}`) +}) + +mock.module("node:child_process", () => ({ + execSync: execSyncMock, + execFileSync: execFileSyncMock, +})) + +const { collectGitDiffStats } = await import("./collect-git-diff-stats") + +describe("collectGitDiffStats", () => { + test("uses execFileSync with arg arrays (no shell injection)", () => { + //#given + const directory = "/tmp/safe-repo;touch /tmp/pwn" + + //#when + const result = collectGitDiffStats(directory) + + //#then + expect(execSyncMock).not.toHaveBeenCalled() + expect(execFileSyncMock).toHaveBeenCalledTimes(2) + + const [firstCallFile, firstCallArgs, firstCallOpts] = execFileSyncMock.mock + .calls[0]! as unknown as [string, string[], { cwd?: string }] + expect(firstCallFile).toBe("git") + expect(firstCallArgs).toEqual(["diff", "--numstat", "HEAD"]) + expect(firstCallOpts.cwd).toBe(directory) + expect(firstCallArgs.join(" ")).not.toContain(directory) + + const [secondCallFile, secondCallArgs, secondCallOpts] = execFileSyncMock.mock + .calls[1]! as unknown as [string, string[], { cwd?: string }] + expect(secondCallFile).toBe("git") + expect(secondCallArgs).toEqual(["status", "--porcelain"]) + expect(secondCallOpts.cwd).toBe(directory) + expect(secondCallArgs.join(" ")).not.toContain(directory) + + expect(result).toEqual([ + { + path: "file.ts", + added: 1, + removed: 2, + status: "modified", + }, + ]) + }) +}) diff --git a/src/shared/git-worktree/collect-git-diff-stats.ts b/src/shared/git-worktree/collect-git-diff-stats.ts index 158a09d82..49a98fe2f 100644 --- a/src/shared/git-worktree/collect-git-diff-stats.ts +++ b/src/shared/git-worktree/collect-git-diff-stats.ts @@ -1,11 +1,11 @@ -import { execSync } from "node:child_process" +import { execFileSync } from "node:child_process" import { parseGitStatusPorcelain } from "./parse-status-porcelain" import { parseGitDiffNumstat } from "./parse-diff-numstat" import type { GitFileStat } from "./types" export function collectGitDiffStats(directory: string): GitFileStat[] { try { - const diffOutput = execSync("git diff --numstat HEAD", { + const diffOutput = execFileSync("git", ["diff", "--numstat", "HEAD"], { cwd: directory, encoding: "utf-8", timeout: 5000, @@ -14,7 +14,7 @@ export function collectGitDiffStats(directory: string): GitFileStat[] { if (!diffOutput) return [] - const statusOutput = execSync("git status --porcelain", { + const statusOutput = execFileSync("git", ["status", "--porcelain"], { cwd: directory, encoding: "utf-8", timeout: 5000,