mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-28 18:09:14 +02:00
Compare commits
1 Commits
agent/lamb
...
agent/j/50
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4653d5baac |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
44
server/internal/daemon/agent_cli_path.go
Normal file
44
server/internal/daemon/agent_cli_path.go
Normal 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 ""
|
||||
}
|
||||
73
server/internal/daemon/agent_cli_path_test.go
Normal file
73
server/internal/daemon/agent_cli_path_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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/.
|
||||
|
||||
Reference in New Issue
Block a user