Skip to content

Commit b8e86a1

Browse files
committed
fix(plugins): stage git plugin clone on target filesystem to avoid EXDEV
Git plugin installs cloned into os.tmpdir() then atomically renamed the staged repo into ~/.openclaw/git/. When /tmp and the state dir live on different filesystems (common in Docker with bind-mounted volumes), the rename failed with EXDEV: cross-device link not permitted. Stage the clone under the managed git root (same filesystem as the destination) so the final rename never crosses devices. Falls back to os.tmpdir() when the managed root cannot be prepared, preserving prior behavior. Closes #99885
1 parent 9a0c9f8 commit b8e86a1

3 files changed

Lines changed: 216 additions & 136 deletions

File tree

src/infra/install-source-utils.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,12 +135,23 @@ export type NpmIntegrityDrift = {
135135
actualIntegrity: string;
136136
};
137137

138-
/** Runs a callback in a private temp directory and removes it afterward. */
138+
/**
139+
* Runs a callback in a private temp directory and removes it afterward.
140+
*
141+
* By default the temp directory is created under `os.tmpdir()`. Callers that
142+
* later move the staged result into a persistent location with an atomic
143+
* `rename()` should pass `rootDir` pointing at (a directory on) the same
144+
* filesystem as that destination. Otherwise the rename can fail with `EXDEV`
145+
* ("cross-device link not permitted") when `/tmp` and the destination live on
146+
* different mounts — common in containers with bind-mounted state dirs.
147+
*/
139148
export async function withTempDir<T>(
140149
prefix: string,
141150
fn: (tmpDir: string) => Promise<T>,
151+
options?: { rootDir?: string },
142152
): Promise<T> {
143-
return await withTempWorkspace({ rootDir: os.tmpdir(), prefix }, async (tmp) => fn(tmp.dir));
153+
const rootDir = options?.rootDir ?? os.tmpdir();
154+
return await withTempWorkspace({ rootDir, prefix }, async (tmp) => fn(tmp.dir));
144155
}
145156

146157
/** Resolves and validates a user-supplied archive path before extraction. */

src/plugins/git-install.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,47 @@ describe("installPluginFromGitSpec", () => {
486486
}
487487
});
488488

489+
it("stages the clone under the managed git dir so replacement never crosses filesystems (#99885)", async () => {
490+
const gitDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-git-install-stage-"));
491+
try {
492+
runCommandWithTimeoutMock
493+
.mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" })
494+
.mockResolvedValueOnce({ code: 0, stdout: "abc123\n", stderr: "" })
495+
.mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" });
496+
installPluginFromInstalledPackageDirMock.mockImplementation(
497+
async (params: { packageDir: string }) => {
498+
await fs.mkdir(params.packageDir, { recursive: true });
499+
return {
500+
ok: true,
501+
pluginId: "demo",
502+
targetDir: params.packageDir,
503+
version: "1.2.3",
504+
extensions: ["index.js"],
505+
};
506+
},
507+
);
508+
509+
const result = await installPluginFromGitSpec({
510+
spec: "git:github.com/acme/demo",
511+
gitDir,
512+
});
513+
expect(result.ok).toBe(true);
514+
if (!result.ok) {
515+
throw new Error(result.error);
516+
}
517+
518+
// The clone destination must live under the resolved managed git dir, not
519+
// os.tmpdir(). Staging elsewhere makes the final atomic rename into the
520+
// persistent repo cross filesystems and fail with EXDEV in containers.
521+
const cloneArgv = commandArgvAt(0);
522+
const cloneDest = cloneArgv[cloneArgv.length - 1];
523+
const resolvedGitDir = path.resolve(gitDir);
524+
expect(path.resolve(cloneDest).startsWith(resolvedGitDir)).toBe(true);
525+
} finally {
526+
await fs.rm(gitDir, { recursive: true, force: true });
527+
}
528+
});
529+
489530
it("uses a credential-free managed repo path for authenticated git URLs", async () => {
490531
const gitDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-git-install-path-"));
491532
try {

0 commit comments

Comments
 (0)