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