From 254a02a79532fd554ca520b36737bc8fa2f74f98 Mon Sep 17 00:00:00 2001 From: Bowser Date: Wed, 12 Aug 2026 21:23:59 -0700 Subject: [PATCH] MUL-5799: feat(daemon): allow relocating per-task temp dirs via MULTICA_AGENT_TEMP_BASE (#6481) * feat(daemon): allow relocating per-task temp dirs via MULTICA_AGENT_TEMP_BASE (#6475) On Linux/macOS the private per-task temp dir was always created under /tmp and the daemon's own TMPDIR was ignored, so a self-hosted operator could not move agent temp data to a larger disk. When MULTICA_AGENT_TEMP_BASE is set, it must be an absolute path to an existing directory and is used as the MkdirTemp base; invalid values fail task startup with a clear error instead of silently falling back. Unset keeps the previous platform default byte-for-byte. Random naming, 0700 permissions, TMPDIR/TMP/TEMP injection, the agent custom_env blocklist, and task-exit cleanup are unchanged. Windows is untouched. Co-authored-by: multica-agent * feat(daemon): validate MULTICA_AGENT_TEMP_BASE is writable (#6475) An existing directory the daemon cannot write to still failed task startup, but as a bare mkdir error from deeper in the run. Probe writability while resolving the base so the failure names the variable the operator set. The probe runs only when the variable is set, leaving the default /tmp path byte-for-byte unchanged. Cover the contract at the level operators experience it: an unusable base fails runTask with a message naming the variable, and the agent never starts. Also restore the environment in the unset subtest, which previously called os.Unsetenv without a matching restore and could leak into later tests in the package. Co-authored-by: multica-agent * chore: remove accidentally committed commitmsg.txt (#6475) A commit message draft was swept into the previous commit by git add -A. It is not part of the product surface and does not belong in the tree. Co-authored-by: multica-agent * docs: scope the sun_path limit to Unix-socket platforms (#6475) The 108/104 byte sun_path ceiling only constrains platforms that use Unix domain sockets; the previous phrasing read as a cross-platform rule. Co-authored-by: multica-agent * fix(daemon): address temp base review feedback (#6475) Keep MULTICA_AGENT_TEMP_BASE Unix-only, use the real task directory creation as the sole writability check, and wrap configured-base failures with the variable name. Remove the diagnostic probe and its possible residue path. Add the missing localized documentation and pin private task directory permissions at 0700 in tests. Co-authored-by: multica-agent --------- Co-authored-by: multica-agent --- .../content/docs/environment-variables.ja.mdx | 1 + .../content/docs/environment-variables.ko.mdx | 1 + .../content/docs/environment-variables.mdx | 1 + .../content/docs/environment-variables.zh.mdx | 1 + server/internal/daemon/daemon.go | 31 +- server/internal/daemon/workdir_race_test.go | 294 ++++++++++++++++++ 6 files changed, 328 insertions(+), 1 deletion(-) diff --git a/apps/docs/content/docs/environment-variables.ja.mdx b/apps/docs/content/docs/environment-variables.ja.mdx index 9541cd920..7818ea2b6 100644 --- a/apps/docs/content/docs/environment-variables.ja.mdx +++ b/apps/docs/content/docs/environment-variables.ja.mdx @@ -207,6 +207,7 @@ API key と base URL の両方が空の場合、サーバーサイド LLM 生成 | `MULTICA_DAEMON_AUTO_UPDATE_INTERVAL` | `6h` | 更新チェックの間隔 | | `MULTICA_DAEMON_AUTO_RELOAD` | `true` | 帯域外で置き換えられた `multica` バイナリ(`brew upgrade`、再ダウンロード、ローカルビルド)へ再起動して追従するかどうか。`MULTICA_DAEMON_AUTO_UPDATE` とは独立 | | `MULTICA_WORKSPACES_ROOT` | `~/multica_workspaces` | タスク作業ディレクトリのルート | +| `MULTICA_AGENT_TEMP_BASE` | `/tmp`(Linux/macOS) | Linux/macOS 専用。作業ごとのプライベート一時ディレクトリの親ディレクトリ。既存かつ書き込み可能な絶対パスである必要があり、無効な値の場合は `/tmp` にフォールバックせず作業の開始に失敗します。短いパスを選んでください。子ツールがその配下に AF_UNIX ソケットを作成する場合があり、`sun_path` の上限は Linux で 108 バイト、macOS で 104 バイトです | | `MULTICA_KEEP_ENV_AFTER_TASK` | `false` | デバッグ用にタスクディレクトリを保持 | 各 AI コーディングツールは `MULTICA__PATH` と `MULTICA__MODEL` でコマンドパスとデフォルトモデルを上書きできます。QwenPaw だけは例外で、Multica がモデルを渡さないため `MULTICA_QWENPAW_MODEL` はありません。詳しくは [AI コーディングツール比較](/providers#モデルの取得元) を参照してください。マシン全体のデフォルト引数 `MULTICA__ARGS` は、現在 Claude Code、Codex、CodeBuddy、Qwen Code、QwenPaw の 5 ツールに対応しています。対応する変数は `MULTICA_CLAUDE_ARGS`、`MULTICA_CODEX_ARGS`、`MULTICA_CODEBUDDY_ARGS`、`MULTICA_QWEN_ARGS`、`MULTICA_QWENPAW_ARGS` です。例: diff --git a/apps/docs/content/docs/environment-variables.ko.mdx b/apps/docs/content/docs/environment-variables.ko.mdx index 879ae5afb..bcf0cd8e8 100644 --- a/apps/docs/content/docs/environment-variables.ko.mdx +++ b/apps/docs/content/docs/environment-variables.ko.mdx @@ -207,6 +207,7 @@ API key와 base URL이 모두 비어 있으면 서버 측 LLM 생성이 꺼지 | `MULTICA_DAEMON_AUTO_UPDATE_INTERVAL` | `6h` | 업데이트 확인 간격 | | `MULTICA_DAEMON_AUTO_RELOAD` | `true` | 외부에서 교체된 `multica` 바이너리(`brew upgrade`, 재다운로드, 로컬 빌드)로 재시작할지 여부. `MULTICA_DAEMON_AUTO_UPDATE`와 독립적 | | `MULTICA_WORKSPACES_ROOT` | `~/multica_workspaces` | 실행 태스크 작업 디렉터리의 루트 | +| `MULTICA_AGENT_TEMP_BASE` | `/tmp`(Linux/macOS) | Linux/macOS 전용. 작업별 비공개 임시 디렉터리의 상위 디렉터리입니다. 기존의 쓰기 가능한 절대 경로여야 하며, 값이 잘못되면 `/tmp`로 대체하지 않고 작업 시작에 실패합니다. 짧은 경로를 선택하세요. 하위 도구가 그 아래에 AF_UNIX 소켓을 만들 수 있으며 `sun_path` 제한은 Linux에서 108바이트, macOS에서 104바이트입니다 | | `MULTICA_KEEP_ENV_AFTER_TASK` | `false` | 디버깅을 위해 실행 태스크 디렉터리 유지 | 각 AI 코딩 도구는 `MULTICA__PATH`와 `MULTICA__MODEL`로 명령 경로와 기본 모델을 덮어쓸 수 있습니다. QwenPaw만 예외로, Multica가 모델을 전달하지 않기 때문에 `MULTICA_QWENPAW_MODEL`이 없습니다. 자세한 내용은 [AI 코딩 도구 비교](/providers#모델-출처)를 참고하세요. 컴퓨터 전체 기본 인수 `MULTICA__ARGS`는 현재 Claude Code, Codex, CodeBuddy, Qwen Code, QwenPaw 다섯 도구를 지원합니다. 해당 변수는 `MULTICA_CLAUDE_ARGS`, `MULTICA_CODEX_ARGS`, `MULTICA_CODEBUDDY_ARGS`, `MULTICA_QWEN_ARGS`, `MULTICA_QWENPAW_ARGS`입니다. 예시: diff --git a/apps/docs/content/docs/environment-variables.mdx b/apps/docs/content/docs/environment-variables.mdx index 87d415730..e399ae1e9 100644 --- a/apps/docs/content/docs/environment-variables.mdx +++ b/apps/docs/content/docs/environment-variables.mdx @@ -207,6 +207,7 @@ The variables below are read on the computer that runs your agents, not in the A | `MULTICA_DAEMON_AUTO_UPDATE_INTERVAL` | `6h` | Update check interval | | `MULTICA_DAEMON_AUTO_RELOAD` | `true` | Whether to restart into a `multica` binary replaced on disk out of band (`brew upgrade`, a re-download, a local build). Independent of `MULTICA_DAEMON_AUTO_UPDATE` | | `MULTICA_WORKSPACES_ROOT` | `~/multica_workspaces` | Root directory for task working directories | +| `MULTICA_AGENT_TEMP_BASE` | `/tmp` (Linux/macOS) | Linux/macOS only. Parent directory for private per-task temp dirs; must be an existing, writable absolute directory, and an invalid value fails task startup instead of falling back to `/tmp`. Pick a short path — child tools may bind AF_UNIX sockets under it, and `sun_path` is limited to 108 bytes on Linux and 104 on macOS | | `MULTICA_KEEP_ENV_AFTER_TASK` | `false` | Keep task directories for debugging | Each AI coding tool accepts `MULTICA__PATH` and `MULTICA__MODEL` to override the command path and default model. QwenPaw is the exception: it has no `MULTICA_QWENPAW_MODEL`, because Multica never sends it a model — see [AI coding tools comparison](/providers#model-sources). Machine-wide default arguments via `MULTICA__ARGS` are currently supported for five tools: Claude Code, Codex, CodeBuddy, Qwen Code, and QwenPaw. The variables are `MULTICA_CLAUDE_ARGS`, `MULTICA_CODEX_ARGS`, `MULTICA_CODEBUDDY_ARGS`, `MULTICA_QWEN_ARGS`, and `MULTICA_QWENPAW_ARGS`. For example: diff --git a/apps/docs/content/docs/environment-variables.zh.mdx b/apps/docs/content/docs/environment-variables.zh.mdx index 3f0f6c111..4b63bdd05 100644 --- a/apps/docs/content/docs/environment-variables.zh.mdx +++ b/apps/docs/content/docs/environment-variables.zh.mdx @@ -207,6 +207,7 @@ API key 与 base URL 都为空时,服务端 LLM 生成关闭,调用方会使 | `MULTICA_DAEMON_AUTO_UPDATE_INTERVAL` | `6h` | 检查更新的间隔 | | `MULTICA_DAEMON_AUTO_RELOAD` | `true` | 是否在 `multica` 二进制被带外替换(`brew upgrade`、重新下载、本地构建)后重启到新二进制。与 `MULTICA_DAEMON_AUTO_UPDATE` 相互独立 | | `MULTICA_WORKSPACES_ROOT` | `~/multica_workspaces` | 执行任务工作目录的根目录 | +| `MULTICA_AGENT_TEMP_BASE` | `/tmp`(Linux/macOS) | 仅适用于 Linux/macOS。私有 task 临时目录的父目录;必须是现有、可写的绝对路径,无效值会使 task 启动失败,不会回退到 `/tmp`。请选择较短的路径——子工具可能会在其中绑定 AF_UNIX socket;Linux 的 `sun_path` 上限为 108 字节,macOS 为 104 字节 | | `MULTICA_KEEP_ENV_AFTER_TASK` | `false` | 保留执行任务目录用于调试 | 各 AI 编程工具可以使用 `MULTICA__PATH` 和 `MULTICA__MODEL` 覆盖命令路径与默认模型。QwenPaw 是例外:它没有 `MULTICA_QWENPAW_MODEL`,因为 Multica 不会向它传模型,详见 [AI 编程工具对比](/providers#模型来源)。全机默认参数 `MULTICA__ARGS` 目前支持 Claude Code、Codex、CodeBuddy、Qwen Code 和 QwenPaw 五款工具。对应变量是 `MULTICA_CLAUDE_ARGS`、`MULTICA_CODEX_ARGS`、`MULTICA_CODEBUDDY_ARGS`、`MULTICA_QWEN_ARGS` 和 `MULTICA_QWENPAW_ARGS`。例如: diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index 2824b9fad..bd6a314c3 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -7626,16 +7626,45 @@ func ensureTaskTempDir(envRoot string, workspaceID string, taskID string) (strin if taskID == "" { return "", errors.New("task id is empty") } - dir, err := os.MkdirTemp(socketSafeTempBaseDir(), "multica-task-") + base, overrideConfigured, err := taskTempBaseDir() if err != nil { return "", err } + dir, err := os.MkdirTemp(base, "multica-task-") + if err != nil { + if overrideConfigured { + return "", fmt.Errorf("MULTICA_AGENT_TEMP_BASE: create task temp dir: %w", err) + } + return "", err + } if err := os.Chmod(dir, 0o700); err != nil { return "", err } return dir, nil } +// taskTempBaseDir resolves the parent directory for private per-task temp +// dirs on Linux and macOS. The daemon operator can relocate it with +// MULTICA_AGENT_TEMP_BASE, which must be an absolute path to an existing, +// writable directory; an invalid value fails task startup instead of silently +// falling back. Windows ignores the variable. Unset keeps the platform default +// exactly as before, down to the syscalls made. +// Operators should pick a short path: child tools may bind AF_UNIX sockets +// under $TMPDIR (sun_path is 108 bytes on Linux, 104 on macOS). +func taskTempBaseDir() (string, bool, error) { + if runtime.GOOS == "windows" { + return socketSafeTempBaseDir(), false, nil + } + base := strings.TrimSpace(os.Getenv("MULTICA_AGENT_TEMP_BASE")) + if base == "" { + return socketSafeTempBaseDir(), false, nil + } + if !filepath.IsAbs(base) { + return "", true, fmt.Errorf("MULTICA_AGENT_TEMP_BASE must be an absolute path, got %q", base) + } + return base, true, nil +} + func socketSafeTempBaseDir() string { if os.PathSeparator == '/' { if info, err := os.Stat("/tmp"); err == nil && info.IsDir() { diff --git a/server/internal/daemon/workdir_race_test.go b/server/internal/daemon/workdir_race_test.go index 5624e8fb4..6b7e294a9 100644 --- a/server/internal/daemon/workdir_race_test.go +++ b/server/internal/daemon/workdir_race_test.go @@ -272,6 +272,300 @@ printf '%s\n' '{"type":"result","subtype":"success","is_error":false,"session_id } } +// TestTaskTempBaseDir covers the MULTICA_AGENT_TEMP_BASE validation contract: +// Windows ignores it, while Unix honors a valid absolute directory and reports +// unusable configured bases from the real task-directory creation instead of +// silently falling back to /tmp. +func TestTaskTempBaseDir(t *testing.T) { + if runtime.GOOS == "windows" { + t.Setenv("MULTICA_AGENT_TEMP_BASE", `C:\configured-but-ignored`) + got, configured, err := taskTempBaseDir() + if err != nil { + t.Fatalf("taskTempBaseDir(): %v", err) + } + if configured { + t.Fatal("taskTempBaseDir() marked Windows override as configured") + } + if got != socketSafeTempBaseDir() { + t.Fatalf("taskTempBaseDir() = %q, want platform default %q", got, socketSafeTempBaseDir()) + } + return + } + + validBase := t.TempDir() + cases := []struct { + name string + value string + set bool + want string + wantConfigured bool + wantErr bool + }{ + {name: "unset keeps platform default", set: false, want: socketSafeTempBaseDir()}, + {name: "empty keeps platform default", set: true, value: " ", want: socketSafeTempBaseDir()}, + {name: "valid absolute dir is honored", set: true, value: validBase, want: validBase, wantConfigured: true}, + {name: "relative path rejected", set: true, value: "relative/base", wantConfigured: true, wantErr: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Register the restore hook in both branches: t.Setenv remembers + // whether the variable was originally set and undoes either case. + t.Setenv("MULTICA_AGENT_TEMP_BASE", tc.value) + if !tc.set { + if err := os.Unsetenv("MULTICA_AGENT_TEMP_BASE"); err != nil { + t.Fatalf("unset MULTICA_AGENT_TEMP_BASE: %v", err) + } + } + got, configured, err := taskTempBaseDir() + if configured != tc.wantConfigured { + t.Fatalf("taskTempBaseDir() configured = %v, want %v", configured, tc.wantConfigured) + } + if tc.wantErr { + if err == nil { + t.Fatalf("taskTempBaseDir() = %q, want error", got) + } + // The message must name the variable the operator set, so the + // failure is actionable rather than a bare mkdir/stat error. + if !strings.Contains(err.Error(), "MULTICA_AGENT_TEMP_BASE") { + t.Fatalf("error %q does not mention MULTICA_AGENT_TEMP_BASE", err) + } + return + } + if err != nil { + t.Fatalf("taskTempBaseDir(): %v", err) + } + if got != tc.want { + t.Fatalf("taskTempBaseDir() = %q, want %q", got, tc.want) + } + }) + } + + t.Run("configured base creates private 0700 task dir", func(t *testing.T) { + t.Setenv("MULTICA_AGENT_TEMP_BASE", validBase) + dir, err := ensureTaskTempDir("root", "ws", "task") + if err != nil { + t.Fatalf("ensureTaskTempDir(): %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("stat task temp dir: %v", err) + } + if info.Mode().Perm() != 0o700 { + t.Fatalf("task temp dir mode = %o, want 0700", info.Mode().Perm()) + } + }) + + notDir := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(notDir, []byte("x"), 0o600); err != nil { + t.Fatalf("write notDir fixture: %v", err) + } + readOnlyBase := filepath.Join(t.TempDir(), "read-only") + if err := os.Mkdir(readOnlyBase, 0o500); err != nil { + t.Fatalf("mkdir readOnlyBase fixture: %v", err) + } + // t.TempDir cleanup needs to descend into it again. + t.Cleanup(func() { _ = os.Chmod(readOnlyBase, 0o700) }) + + for _, tc := range []struct { + name string + base string + }{ + {name: "missing dir rejected", base: filepath.Join(validBase, "missing")}, + {name: "non-directory rejected", base: notDir}, + {name: "non-writable dir rejected", base: readOnlyBase}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("MULTICA_AGENT_TEMP_BASE", tc.base) + dir, err := ensureTaskTempDir("root", "ws", "task") + if err == nil { + _ = os.RemoveAll(dir) + if tc.base == readOnlyBase { + t.Skip("process can write to the read-only fixture") + } + t.Fatalf("ensureTaskTempDir() = %q with unusable MULTICA_AGENT_TEMP_BASE, want error", dir) + } + if !strings.Contains(err.Error(), "MULTICA_AGENT_TEMP_BASE") { + t.Fatalf("error %q does not mention MULTICA_AGENT_TEMP_BASE", err) + } + }) + } +} + +// TestRunTask_TaskTempBaseOverride is the MULTICA_AGENT_TEMP_BASE counterpart +// of TestRunTask_InjectsPrivateTaskTempDir: with the variable set, all three +// temp vars point at one fresh private dir under the configured base, agent +// custom_env still cannot override them, and the dir is removed on task exit. +func TestRunTask_TaskTempBaseOverride(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell-script agent fixture is POSIX-only") + } + + tempBase := t.TempDir() + t.Setenv("MULTICA_AGENT_TEMP_BASE", tempBase) + + workspacesRoot := t.TempDir() + workspaceID := "ws-temp-base" + taskID := "task-temp-base" + + captureFile := filepath.Join(t.TempDir(), "agent-env.txt") + fakeBin := filepath.Join(t.TempDir(), "claude") + script := `#!/bin/sh +printf 'TMPDIR=%s\nTMP=%s\nTEMP=%s\n' "$TMPDIR" "$TMP" "$TEMP" > "$CAPTURE_FILE" +IFS= read -r _ +printf '%s\n' '{"type":"system","session_id":"sess-temp-base"}' +printf '%s\n' '{"type":"result","subtype":"success","is_error":false,"session_id":"sess-temp-base","result":"done"}' +` + if err := os.WriteFile(fakeBin, []byte(script), 0o755); err != nil { + t.Fatalf("write fake agent: %v", err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + + d := &Daemon{ + client: NewClient(srv.URL), + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + workspaces: make(map[string]*workspaceState), + runtimeIndex: map[string]Runtime{"rt-1": {ID: "rt-1", Provider: "claude"}}, + activeEnvRoots: make(map[string]int), + cfg: Config{ + WorkspacesRoot: workspacesRoot, + AgentTimeout: 5 * time.Second, + ServerBaseURL: srv.URL, + Agents: map[string]AgentEntry{ + "claude": {Path: fakeBin, Model: ""}, + }, + }, + } + + task := Task{ + ID: taskID, + WorkspaceID: workspaceID, + RuntimeID: "rt-1", + IssueID: "issue-temp-base", + AuthToken: "mat_temp_base", + Agent: &AgentData{ + ID: "agent-temp-base", + Name: "test-agent", + CustomEnv: map[string]string{ + "CAPTURE_FILE": captureFile, + "TMPDIR": "/shared/tmp", + "TMP": "/shared/tmp", + "TEMP": "/shared/tmp", + }, + }, + } + + taskLog := slog.New(slog.NewTextHandler(io.Discard, nil)) + result, err := d.runTask(context.Background(), task, "claude", 0, taskLog) + if err != nil { + t.Fatalf("runTask(): %v", err) + } + if result.Status != "completed" { + t.Fatalf("runTask status = %q, want completed (comment=%q)", result.Status, result.Comment) + } + + raw, err := os.ReadFile(captureFile) + if err != nil { + t.Fatalf("read captured agent env: %v", err) + } + got := make(map[string]string) + for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") { + key, value, ok := strings.Cut(line, "=") + if !ok { + t.Fatalf("malformed captured env line %q", line) + } + got[key] = value + } + taskTempDir := got["TMPDIR"] + if taskTempDir == "" { + t.Fatal("TMPDIR was not captured") + } + for _, key := range []string{"TMP", "TEMP"} { + if got[key] != taskTempDir { + t.Fatalf("%s = %q, want same private task temp dir %q", key, got[key], taskTempDir) + } + } + if filepath.Dir(taskTempDir) != tempBase { + t.Fatalf("task temp dir %q is not directly under configured base %q", taskTempDir, tempBase) + } + if _, err := os.Stat(taskTempDir); !os.IsNotExist(err) { + t.Fatalf("expected task temp dir %q to be cleaned after run, stat err=%v", taskTempDir, err) + } +} + +// TestRunTask_TaskTempBaseInvalidFailsStartup pins the "no silent fallback" +// half of the contract at the level operators experience it: an unusable +// MULTICA_AGENT_TEMP_BASE fails the task with a message naming the variable, +// and the agent never starts against a /tmp dir it did not ask for. +func TestRunTask_TaskTempBaseInvalidFailsStartup(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell-script agent fixture is POSIX-only") + } + + missingBase := filepath.Join(t.TempDir(), "does-not-exist") + t.Setenv("MULTICA_AGENT_TEMP_BASE", missingBase) + + workspacesRoot := t.TempDir() + captureFile := filepath.Join(t.TempDir(), "agent-env.txt") + fakeBin := filepath.Join(t.TempDir(), "claude") + script := `#!/bin/sh +printf 'ran\n' > "$CAPTURE_FILE" +` + if err := os.WriteFile(fakeBin, []byte(script), 0o755); err != nil { + t.Fatalf("write fake agent: %v", err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + + d := &Daemon{ + client: NewClient(srv.URL), + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + workspaces: make(map[string]*workspaceState), + runtimeIndex: map[string]Runtime{"rt-1": {ID: "rt-1", Provider: "claude"}}, + activeEnvRoots: make(map[string]int), + cfg: Config{ + WorkspacesRoot: workspacesRoot, + AgentTimeout: 5 * time.Second, + ServerBaseURL: srv.URL, + Agents: map[string]AgentEntry{ + "claude": {Path: fakeBin, Model: ""}, + }, + }, + } + + task := Task{ + ID: "task-temp-base-invalid", + WorkspaceID: "ws-temp-base-invalid", + RuntimeID: "rt-1", + IssueID: "issue-temp-base-invalid", + AuthToken: "mat_temp_base_invalid", + Agent: &AgentData{ + ID: "agent-temp-base-invalid", + Name: "test-agent", + CustomEnv: map[string]string{"CAPTURE_FILE": captureFile}, + }, + } + + taskLog := slog.New(slog.NewTextHandler(io.Discard, nil)) + _, err := d.runTask(context.Background(), task, "claude", 0, taskLog) + if err == nil { + t.Fatal("runTask() succeeded with an unusable MULTICA_AGENT_TEMP_BASE, want failure") + } + if !strings.Contains(err.Error(), "MULTICA_AGENT_TEMP_BASE") { + t.Fatalf("runTask() error = %v, want it to name MULTICA_AGENT_TEMP_BASE", err) + } + if _, statErr := os.Stat(captureFile); !os.IsNotExist(statErr) { + t.Fatalf("agent ran despite the temp-base failure, stat err=%v", statErr) + } +} + func TestRunTask_ExtendsPrepareLeaseDuringStartTask(t *testing.T) { oldRefresh := taskPrepareLeaseRefresh oldTimeout := taskPrepareLeaseTimeout