Skip to content

Commit 247a068

Browse files
committed
fix: avoid gateway cwd for node exec (#58977) (thanks @Starhappysh)
1 parent 50b270a commit 247a068

4 files changed

Lines changed: 61 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,9 @@ Docs: https://docs.openclaw.ai
9191
- Telegram/exec approvals: rewrite shared `/approve … allow-always` callback payloads to `/approve … always` before Telegram button rendering so plugin approval IDs still fit Telegram's `callback_data` limit and keep the Allow Always action visible. (#59217) Thanks @jameslcowan.
9292
- Cron/exec timeouts: surface timed-out `exec` and `bash` failures in isolated cron runs even when `verbose: off`, including custom session-target cron jobs, so scheduled runs stop failing silently. (#58247) Thanks @skainguyen1412.
9393
- Telegram/exec approvals: fall back to the origin session key for async approval followups and keep resume-failure status delivery sanitized so Telegram followups still land without leaking raw exec metadata. (#59351) Thanks @seonang.
94+
<<<<<<< HEAD
9495
- Node-host/exec approvals: bind `pnpm dlx` invocations through the approval planner's mutable-script path so the effective runtime command is resolved for approval instead of being left unbound. (#58374)
96+
- Exec/node hosts: stop forwarding the gateway workspace cwd to remote node exec when no workdir was explicitly requested, so cross-platform node approvals fall back to the node default cwd instead of failing with `SYSTEM_RUN_DENIED`. (#58977) Thanks @Starhappysh.
9597

9698
## 2026.4.2
9799

src/agents/bash-tools.exec-host-node.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ export async function executeNodeHostCommand(
121121
}
122122
const runArgv = prepared.plan.argv;
123123
const runRawCommand = prepared.plan.commandText;
124-
const runCwd = prepared.plan.cwd ?? params.workdir ?? undefined;
124+
const runCwd = prepared.plan.cwd ?? params.workdir;
125125
const runAgentId = prepared.plan.agentId ?? params.agentId;
126126
const runSessionKey = prepared.plan.sessionKey ?? params.sessionKey;
127127

@@ -453,4 +453,3 @@ export async function executeNodeHostCommand(
453453
} satisfies ExecToolDetails,
454454
};
455455
}
456-

src/agents/bash-tools.exec.approval-id.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ function expectPendingApprovalText(
9494
nodeId?: string;
9595
interactive?: boolean;
9696
allowedDecisions?: string;
97+
cwdText?: string;
9798
},
9899
) {
99100
expect(result.details.status).toBe("approval-pending");
@@ -107,7 +108,7 @@ function expectPendingApprovalText(
107108
if (options.nodeId) {
108109
expect(pendingText).toContain(`Node: ${options.nodeId}`);
109110
}
110-
expect(pendingText).toContain(`CWD: ${process.cwd()}`);
111+
expect(pendingText).toContain(`CWD: ${options.cwdText ?? process.cwd()}`);
111112
expect(pendingText).toContain("Command:\n```sh\n");
112113
expect(pendingText).toContain(options.command);
113114
if (options.interactive) {
@@ -283,6 +284,7 @@ describe("exec approvals", () => {
283284
nodeId: "node-1",
284285
interactive: true,
285286
allowedDecisions: "allow-once|deny",
287+
cwdText: "(node default)",
286288
});
287289
const approvalId = details.approvalId;
288290

@@ -387,6 +389,43 @@ describe("exec approvals", () => {
387389
expect(prepareCwd).toBe(remoteWorkdir);
388390
});
389391

392+
it("does not forward the gateway default cwd to node exec when workdir is omitted", async () => {
393+
const gatewayWorkspace = "/gateway/workspace";
394+
let prepareHasCwd = false;
395+
let prepareCwd: string | undefined;
396+
397+
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
398+
if (method === "node.invoke") {
399+
const invoke = params as { command?: string; params?: { cwd?: string } };
400+
if (invoke.command === "system.run.prepare") {
401+
prepareHasCwd = Object.hasOwn(invoke.params ?? {}, "cwd");
402+
prepareCwd = invoke.params?.cwd;
403+
return buildPreparedSystemRunPayload(params);
404+
}
405+
if (invoke.command === "system.run") {
406+
return { payload: { success: true, stdout: "ok" } };
407+
}
408+
}
409+
return { ok: true };
410+
});
411+
412+
const tool = createExecTool({
413+
host: "node",
414+
ask: "off",
415+
security: "full",
416+
approvalRunningNoticeMs: 0,
417+
cwd: gatewayWorkspace,
418+
});
419+
420+
const result = await tool.execute("call-node-default-cwd", {
421+
command: "/bin/pwd",
422+
});
423+
424+
expect(result.details.status).toBe("completed");
425+
expect(prepareHasCwd).toBe(false);
426+
expect(prepareCwd).toBeUndefined();
427+
});
428+
390429
it("honors ask=off for elevated gateway exec without prompting", async () => {
391430
const calls: string[] = [];
392431
vi.mocked(callGatewayTool).mockImplementation(async (method) => {

src/agents/bash-tools.exec.ts

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1343,29 +1343,32 @@ export function createExecTool(
13431343
].join("\n"),
13441344
);
13451345
}
1346-
const hasExplicitWorkdir = !!(params.workdir?.trim() || defaults?.cwd);
1347-
const rawWorkdir = params.workdir?.trim() || defaults?.cwd || process.cwd();
1348-
let workdir: string | undefined = rawWorkdir;
1346+
const explicitWorkdir = params.workdir?.trim() || undefined;
1347+
const defaultWorkdir = defaults?.cwd?.trim() || undefined;
1348+
let workdir: string | undefined;
13491349
let containerWorkdir = sandbox?.containerWorkdir;
13501350
if (sandbox) {
1351+
const sandboxWorkdir = explicitWorkdir ?? defaultWorkdir ?? process.cwd();
13511352
const resolved = await resolveSandboxWorkdir({
1352-
workdir: rawWorkdir,
1353+
workdir: sandboxWorkdir,
13531354
sandbox,
13541355
warnings,
13551356
});
13561357
workdir = resolved.hostWorkdir;
13571358
containerWorkdir = resolved.containerWorkdir;
13581359
} else if (host === "node") {
1359-
// For remote node execution, only forward an explicit cwd provided by the
1360-
// user or agent config. When no explicit cwd was given, the gateway's own
1360+
// For remote node execution, only forward a cwd that was explicitly
1361+
// requested on the tool call. The gateway's workspace root is wired in as a
1362+
// local default, but it is not meaningful on the remote node and would
1363+
// recreate the cross-platform approval failure this path is fixing.
1364+
// When no explicit cwd was given, the gateway's own
13611365
// process.cwd() is meaningless on the remote node (especially cross-platform,
13621366
// e.g. Linux gateway + Windows node) and would cause
13631367
// "SYSTEM_RUN_DENIED: approval requires an existing canonical cwd".
13641368
// Passing undefined lets the node use its own default working directory.
1365-
if (!hasExplicitWorkdir) {
1366-
workdir = undefined;
1367-
}
1369+
workdir = explicitWorkdir;
13681370
} else {
1371+
const rawWorkdir = explicitWorkdir ?? defaultWorkdir ?? process.cwd();
13691372
workdir = resolveWorkdir(rawWorkdir, warnings);
13701373
}
13711374
rejectExecApprovalShellCommand(params.command);
@@ -1469,15 +1472,14 @@ export function createExecTool(
14691472
});
14701473
}
14711474

1472-
// After the node early-return above, workdir is guaranteed to be a string
1473-
// (it was initialised from rawWorkdir and only set to undefined inside the
1474-
// node branch which already returned).
1475-
const resolvedWorkdir: string = workdir ?? rawWorkdir;
1475+
if (!workdir) {
1476+
throw new Error("exec internal error: local execution requires a resolved workdir");
1477+
}
14761478

14771479
if (host === "gateway" && !bypassApprovals) {
14781480
const gatewayResult = await processGatewayAllowlist({
14791481
command: params.command,
1480-
workdir: resolvedWorkdir,
1482+
workdir,
14811483
env,
14821484
requestedEnv: params.env,
14831485
pty: params.pty === true && !sandbox,
@@ -1523,12 +1525,12 @@ export function createExecTool(
15231525

15241526
// Preflight: catch a common model failure mode (shell syntax leaking into Python/JS sources)
15251527
// before we execute and burn tokens in cron loops.
1526-
await validateScriptFileForShellBleed({ command: params.command, workdir: resolvedWorkdir });
1528+
await validateScriptFileForShellBleed({ command: params.command, workdir });
15271529

15281530
const run = await runExecProcess({
15291531
command: params.command,
15301532
execCommand: execCommandOverride,
1531-
workdir: resolvedWorkdir,
1533+
workdir,
15321534
env,
15331535
sandbox,
15341536
containerWorkdir,
@@ -1640,4 +1642,3 @@ export function createExecTool(
16401642
}
16411643

16421644
export const execTool = createExecTool();
1643-

0 commit comments

Comments
 (0)