fix: harden ACP spawn workspace resolution

This commit is contained in:
Peter Steinberger
2026-04-04 15:29:03 +09:00
parent d718d17b5b
commit 71c0c2cc06
3 changed files with 63 additions and 2 deletions

View File

@@ -53,6 +53,7 @@ Docs: https://docs.openclaw.ai
- Telegram/replies: preserve explicit topic targets when `replyTo` is present while still inheriting the current topic for same-chat replies without an explicit topic. (#59634) Thanks @dashhuang.
- Telegram/reactions: preserve `reactionNotifications: "own"` across gateway restarts by persisting sent-message ownership state instead of treating cold cache as a permissive fallback. (#59207) Thanks @samzong.
- Telegram/media: preserve `<media:...>` placeholders and `file_id` in captioned messages when Bot API downloads fail, so agents still receive media context. (#59948) Thanks @v1p0r.
- ACP/agents: inherit the target agent workspace for cross-agent ACP spawns, keep missing target workspaces on the backend default cwd path, and surface real access errors instead of silently running in the wrong tree. (#58438) Thanks @zssggle-rgb.
- Telegram/media: keep inbound image attachments readable on upgraded installs where legacy state roots still differ from the managed config-dir media cache. (#59971) Thanks @neeravmakwana.
- Telegram/local Bot API: thread `channels.telegram.apiRoot` through buffered reply-media and album downloads so self-hosted Bot API file paths stop falling back to `api.telegram.org` and 404ing. (#59544) Thanks @SARAMALI15792.
- Telegram/media: add `channels.telegram.network.dangerouslyAllowPrivateNetwork` for trusted fake-IP or transparent-proxy environments where Telegram media downloads resolve `api.telegram.org` to private/internal/special-use addresses.

View File

@@ -646,6 +646,62 @@ describe("spawnAcpDirect", () => {
}
});
it("surfaces non-missing target workspace access failures instead of silently dropping cwd", async () => {
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-acp-spawn-"));
const accessSpy = vi.spyOn(fs, "access");
try {
const mainWorkspace = path.join(workspaceRoot, "main");
const targetWorkspace = path.join(workspaceRoot, "claude-code");
await fs.mkdir(mainWorkspace, { recursive: true });
await fs.mkdir(targetWorkspace, { recursive: true });
replaceSpawnConfig({
...hoisted.state.cfg,
acp: {
...hoisted.state.cfg.acp,
allowedAgents: ["codex", "claude-code"],
},
agents: {
list: [
{
id: "main",
default: true,
workspace: mainWorkspace,
},
{
id: "claude-code",
workspace: targetWorkspace,
},
],
},
});
accessSpy.mockRejectedValueOnce(
Object.assign(new Error("permission denied"), { code: "EACCES" }),
);
const result = await spawnAcpDirect(
{
task: "Inspect the queue owner state",
agentId: "claude-code",
mode: "run",
},
{
agentSessionKey: "agent:main:main",
},
);
expect(result).toEqual({
status: "error",
error: "permission denied",
});
expect(hoisted.initializeSessionMock).not.toHaveBeenCalled();
} finally {
accessSpy.mockRestore();
await fs.rm(workspaceRoot, { recursive: true, force: true });
}
});
it("binds LINE ACP sessions to the current conversation when the channel has no native threads", async () => {
enableLineCurrentConversationBindings();
hoisted.sessionBindingBindMock.mockImplementationOnce(

View File

@@ -382,8 +382,12 @@ async function resolveRuntimeCwdForAcpSpawn(params: {
try {
await fs.access(params.resolvedCwd);
return params.resolvedCwd;
} catch {
return undefined;
} catch (error) {
const code = error instanceof Error ? (error as NodeJS.ErrnoException).code : undefined;
if (code === "ENOENT" || code === "ENOTDIR") {
return undefined;
}
throw error;
}
}