Compare commits

...

1 Commits

Author SHA1 Message Date
Jiang Bohan
4653d5baac fix(daemon): mirror bundled CLI for agent PATH on Windows (MUL-2285)
On Windows Desktop the bundled `multica.exe` lives under
`app.asar.unpacked/resources/bin/`, a directory that agent subprocesses
have been observed to be unable to enumerate (#2672). The previous PATH
injection prepended the daemon's own executable directory, which on
Desktop *is* that unreachable path — `multica issue ...` calls inside
the agent then fail with "Access is denied" and the agent loops.

Reuse the existing managed-CLI infrastructure instead of copying the
binary into every task workdir:

- Desktop: mirror the bundled binary into `app.getPath("userData")/bin/`
  (a user-writable, ACL-clean location) on Windows the first time the
  daemon starts and whenever the bundled binary's size changes (e.g.
  after Desktop auto-updates). Pass that path via `MULTICA_CLI_PATH` to
  the daemon. macOS/Linux are unaffected — the bundled path is reachable
  there, so no mirror happens.

- Daemon: when `MULTICA_CLI_PATH` is set and points to a regular file,
  prepend `filepath.Dir(value)` to the agent PATH; otherwise fall back
  to the existing `os.Executable()` behavior so non-Desktop launches
  (`make daemon`, brew install) keep working unchanged.

This keeps a single `multica.exe` per Desktop install (vs. one per
reused workdir), stays in lockstep with the daemon's auto-update path,
and doesn't pollute task worktrees with a `.multica/` directory.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 21:47:58 +08:00
5 changed files with 225 additions and 11 deletions

View File

@@ -1,8 +1,13 @@
import { app } from "electron";
import { execFile } from "child_process";
import { createHash } from "crypto";
import { createReadStream, createWriteStream, existsSync } from "fs";
import { chmod, mkdir, rename, rm } from "fs/promises";
import {
createReadStream,
createWriteStream,
existsSync,
statSync,
} from "fs";
import { chmod, copyFile, mkdir, rename, rm } from "fs/promises";
import { join, dirname } from "path";
import { pipeline } from "stream/promises";
import { tmpdir } from "os";
@@ -155,3 +160,72 @@ export async function ensureManagedCli(
if (existsSync(target) && !options.forceInstall) return target;
return installFresh();
}
/**
* Returns a CLI path the agent subprocess can reliably read and execute.
*
* On Windows, the bundled CLI lives under
* `%LOCALAPPDATA%\Programs\<install dir>\resources\app.asar.unpacked\resources\bin\multica.exe`.
* Agent subprocesses spawned by the daemon have been observed to fail listing
* or invoking that directory (#2672 / MUL-2285) — `multica` simply doesn't
* resolve, every `multica issue …` call dies, and the agent loops on errors.
*
* Mirror the bundled binary into the user-writable Electron `userData/bin/`
* (same path `managedCliPath()` returns) so the daemon can prepend that dir
* to the agent's PATH instead of the unpacked resources path. The mirror is
* keyed by size: a re-copy fires when the bundled binary's size differs from
* the managed copy (typically because Desktop auto-updated to a new build).
*
* On macOS/Linux the bundled path is reachable, so we just return it.
*
* Returns `null` only when neither bundled nor managed is usable; the daemon
* will then fall back to its own executable directory.
*/
export async function ensureAgentAccessibleCli(
bundledPath: string,
): Promise<string | null> {
if (process.platform !== "win32") {
return existsSync(bundledPath) ? bundledPath : null;
}
const managed = managedCliPath();
const bundledExists = existsSync(bundledPath);
if (bundledExists) {
let needsCopy = !existsSync(managed);
if (!needsCopy) {
try {
const a = statSync(bundledPath);
const b = statSync(managed);
needsCopy = a.size !== b.size;
} catch {
needsCopy = true;
}
}
if (needsCopy) {
try {
await mkdir(dirname(managed), { recursive: true });
// Atomic-ish replace: copy → chmod → rename. Windows can't rename
// over a running executable, but the agent never holds the managed
// copy open at this point — daemon is still starting up.
const tmp = `${managed}.tmp-${process.pid}-${Date.now()}`;
await copyFile(bundledPath, tmp);
await chmod(tmp, 0o755);
await rm(managed, { force: true }).catch(() => {});
await rename(tmp, managed);
console.log(
`[cli-bootstrap] mirrored bundled CLI → ${managed} for agent PATH`,
);
} catch (err) {
console.warn(
"[cli-bootstrap] mirror to managed location failed:",
err,
);
return existsSync(managed) ? managed : bundledPath;
}
}
return managed;
}
return existsSync(managed) ? managed : null;
}

View File

@@ -17,7 +17,11 @@ import {
import { join } from "path";
import { homedir } from "os";
import type { DaemonStatus, DaemonPrefs } from "../shared/daemon-types";
import { ensureManagedCli, managedCliPath } from "./cli-bootstrap";
import {
ensureAgentAccessibleCli,
ensureManagedCli,
managedCliPath,
} from "./cli-bootstrap";
import { decideVersionAction } from "./version-decision";
const DEFAULT_HEALTH_PORT = 19514;
@@ -641,8 +645,23 @@ function profileArgs(active: ActiveProfile): string[] {
// hide CLI self-update UI. Computed lazily so it picks up the PATH fix
// applied by fix-path in main/index.ts — as a top-level const it would
// snapshot process.env at import time, before that block runs.
function desktopSpawnEnv(): NodeJS.ProcessEnv {
return { ...process.env, MULTICA_LAUNCHED_BY: "desktop" };
//
// `MULTICA_CLI_PATH` tells the daemon which `multica` binary the agent
// subprocess should reach for via PATH. On Windows the bundled binary lives
// inside `app.asar.unpacked/` which the agent has been observed to be unable
// to enumerate (#2672 / MUL-2285); pointing at a mirrored copy in
// `managedCliPath()` (under userData) sidesteps the ACL/sandbox quirk
// without forcing the daemon to copy itself into every task workdir.
async function desktopSpawnEnv(
daemonBin: string,
): Promise<NodeJS.ProcessEnv> {
const env: NodeJS.ProcessEnv = {
...process.env,
MULTICA_LAUNCHED_BY: "desktop",
};
const agentCli = await ensureAgentAccessibleCli(daemonBin);
if (agentCli) env.MULTICA_CLI_PATH = agentCli;
return env;
}
async function startDaemon(): Promise<{ success: boolean; error?: string }> {
@@ -661,11 +680,12 @@ async function startDaemon(): Promise<{ success: boolean; error?: string }> {
const args = ["daemon", "start", ...profileArgs(active)];
const spawnEnv = await desktopSpawnEnv(bin);
return new Promise((resolve) => {
execFile(
bin,
args,
{ timeout: 20_000, env: desktopSpawnEnv() },
{ timeout: 20_000, env: spawnEnv },
(err) => {
if (err) {
currentState = "stopped";

View File

@@ -0,0 +1,44 @@
package daemon
import (
"os"
"path/filepath"
)
// MulticaCLIPathEnv is the environment variable a launcher (typically the
// Electron Desktop app) sets to tell the daemon which `multica` binary the
// agent subprocess should use. The daemon prepends `filepath.Dir(value)` to
// the agent PATH so `multica issue …` calls inside the agent resolve.
//
// This indirection exists because the daemon's own executable (returned by
// os.Executable) can sit on a path the agent subprocess cannot enumerate or
// execute. The concrete case is Windows Desktop (#2672 / MUL-2285): the
// bundled binary lives under
//
// %LOCALAPPDATA%\Programs\<...>\app.asar.unpacked\resources\bin\multica.exe
//
// and the agent reports `Access is denied` when listing or invoking that
// directory. The Desktop app routes around it by mirroring the binary into a
// stable user-writable location (its Electron `userData` dir) and pointing
// MULTICA_CLI_PATH at the mirrored copy.
//
// When the env var is unset (CLI run from the user's shell, `make daemon`,
// brew install, etc.) the helper falls back to the daemon's own executable
// directory — same behavior the inline block here had before.
const MulticaCLIPathEnv = "MULTICA_CLI_PATH"
// resolveAgentCLIDir returns the directory to prepend to the agent
// subprocess's PATH so the `multica` CLI resolves. Returns "" only when
// neither the env hint nor os.Executable yields a usable directory, in
// which case the agent inherits the daemon's PATH unmodified.
func resolveAgentCLIDir() string {
if hint := os.Getenv(MulticaCLIPathEnv); hint != "" {
if info, err := os.Stat(hint); err == nil && !info.IsDir() {
return filepath.Dir(hint)
}
}
if selfBin, err := os.Executable(); err == nil {
return filepath.Dir(selfBin)
}
return ""
}

View File

@@ -0,0 +1,73 @@
package daemon
import (
"os"
"path/filepath"
"runtime"
"testing"
)
func TestResolveAgentCLIDir_HintWinsOverSelfExecutable(t *testing.T) {
dir := t.TempDir()
binName := "multica"
if runtime.GOOS == "windows" {
binName = "multica.exe"
}
hint := filepath.Join(dir, binName)
if err := os.WriteFile(hint, []byte("stub"), 0o755); err != nil {
t.Fatalf("write stub: %v", err)
}
t.Setenv(MulticaCLIPathEnv, hint)
got := resolveAgentCLIDir()
if got != dir {
t.Fatalf("expected %q, got %q", dir, got)
}
}
func TestResolveAgentCLIDir_HintMissingFallsBackToSelf(t *testing.T) {
missing := filepath.Join(t.TempDir(), "definitely-not-here", "multica")
t.Setenv(MulticaCLIPathEnv, missing)
got := resolveAgentCLIDir()
if got == "" {
t.Fatal("expected fallback to self executable dir, got empty string")
}
selfBin, err := os.Executable()
if err != nil {
t.Skipf("os.Executable failed in this environment: %v", err)
}
if got != filepath.Dir(selfBin) {
t.Fatalf("expected fallback %q, got %q", filepath.Dir(selfBin), got)
}
}
func TestResolveAgentCLIDir_HintIsDirectoryFallsBack(t *testing.T) {
// A directory path is not a valid CLI binary location; the resolver
// should ignore it and fall back to the self-executable dir.
hintDir := t.TempDir()
t.Setenv(MulticaCLIPathEnv, hintDir)
got := resolveAgentCLIDir()
selfBin, err := os.Executable()
if err != nil {
t.Skipf("os.Executable failed in this environment: %v", err)
}
if got != filepath.Dir(selfBin) {
t.Fatalf("expected fallback %q, got %q", filepath.Dir(selfBin), got)
}
}
func TestResolveAgentCLIDir_NoHintUsesSelfExecutable(t *testing.T) {
t.Setenv(MulticaCLIPathEnv, "")
got := resolveAgentCLIDir()
selfBin, err := os.Executable()
if err != nil {
t.Skipf("os.Executable failed in this environment: %v", err)
}
if got != filepath.Dir(selfBin) {
t.Fatalf("expected %q, got %q", filepath.Dir(selfBin), got)
}
}

View File

@@ -2265,11 +2265,14 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
}
// Ensure the multica CLI is on PATH inside the agent's environment.
// Some runtimes (e.g. Codex) run in an isolated sandbox that may not
// inherit the daemon's PATH. Prepend the directory of the running
// multica binary so that `multica` commands in the agent always resolve.
if selfBin, err := os.Executable(); err == nil {
binDir := filepath.Dir(selfBin)
agentEnv["PATH"] = binDir + string(os.PathListSeparator) + os.Getenv("PATH")
// inherit the daemon's PATH, and the daemon's own executable can live
// on a path the agent process cannot enumerate (Windows Desktop bundles
// the binary inside app.asar.unpacked, see #2672 / MUL-2285). The hint
// in MULTICA_CLI_PATH lets a launcher point us at a known-accessible
// copy; we fall back to the daemon's own directory when no hint is set.
agentEnv["PATH"] = os.Getenv("PATH")
if dir := resolveAgentCLIDir(); dir != "" {
agentEnv["PATH"] = dir + string(os.PathListSeparator) + agentEnv["PATH"]
}
// Point Codex to the per-task CODEX_HOME so it discovers skills natively
// without polluting the system ~/.codex/skills/.