Skip to content

Commit 95b97e5

Browse files
fix(exec): fail invalid explicit workdir before running (#94441)
* fix(exec): fail invalid explicit workdir before running * test(exec): tighten invalid workdir regression * fix(exec): clarify invalid workdir recovery * refactor(exec): centralize workdir resolution * test(exec): update invalid workdir assertion * fix(exec): harden backend workdir contract * fix(exec): map missing backend host workdirs * fix(exec): reject control commands before workdir prep * fix(exec): defer env hook until backend cwd validation * chore(sdk): refresh plugin api baseline * test(agents): drop redundant definition assertions * test(exec): use real config workdirs * test(exec): use tracked temp dirs * test(openshell): keep temp setup local * test: update temp-dir route fixture --------- Co-authored-by: jesse-merhi <[email protected]>
1 parent 13ecca5 commit 95b97e5

30 files changed

Lines changed: 3133 additions & 472 deletions
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
9d5b34975270bb2d16748002c1441ab48fde81af8eb12cc8eb3e341c862232ff plugin-sdk-api-baseline.json
2-
f1a6ff189498d955cad6d6fb912eb4cad7aeb628f89c51d0745e146fe0d163d6 plugin-sdk-api-baseline.jsonl
1+
abdff20b710c6b0fecb5af25603d7cfad7ade80600ca374ebe38f69d78933b50 plugin-sdk-api-baseline.json
2+
630367961e4d14463020f588564c23308159ae2de6e4301418b2b0c471797e70 plugin-sdk-api-baseline.jsonl
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// Openshell tests cover backend-owned exec workdir validation behavior.
2+
import fs from "node:fs/promises";
3+
import os from "node:os";
4+
import path from "node:path";
5+
import type { CreateSandboxBackendParams } from "openclaw/plugin-sdk/sandbox";
6+
import {
7+
createSandboxBrowserConfig,
8+
createSandboxPruneConfig,
9+
createSandboxSshConfig,
10+
} from "openclaw/plugin-sdk/test-fixtures";
11+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
12+
import { createOpenShellSandboxBackendFactory } from "./backend.js";
13+
import { resolveOpenShellPluginConfig } from "./config.js";
14+
15+
const sdkMocks = vi.hoisted(() => ({
16+
runSshSandboxCommand: vi.fn(),
17+
disposeSshSandboxSession: vi.fn(),
18+
}));
19+
20+
const cliMocks = vi.hoisted(() => ({
21+
runOpenShellCli: vi.fn(),
22+
createOpenShellSshSession: vi.fn(),
23+
}));
24+
25+
vi.mock("openclaw/plugin-sdk/sandbox", async (importOriginal) => {
26+
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/sandbox")>();
27+
return {
28+
...actual,
29+
runSshSandboxCommand: sdkMocks.runSshSandboxCommand,
30+
disposeSshSandboxSession: sdkMocks.disposeSshSandboxSession,
31+
};
32+
});
33+
34+
vi.mock("./cli.js", async (importOriginal) => {
35+
const actual = await importOriginal<typeof import("./cli.js")>();
36+
return {
37+
...actual,
38+
runOpenShellCli: cliMocks.runOpenShellCli,
39+
createOpenShellSshSession: cliMocks.createOpenShellSshSession,
40+
};
41+
});
42+
43+
const tempDirs: string[] = [];
44+
45+
function createOpenShellBackendSandboxConfig(): CreateSandboxBackendParams["cfg"] {
46+
return {
47+
mode: "all",
48+
backend: "openshell",
49+
scope: "session",
50+
workspaceAccess: "rw",
51+
workspaceRoot: "/tmp/openclaw-sandboxes",
52+
docker: {
53+
image: "openclaw-sandbox:bookworm-slim",
54+
containerPrefix: "openclaw-sbx-",
55+
workdir: "/workspace",
56+
readOnlyRoot: false,
57+
tmpfs: [],
58+
network: "none",
59+
capDrop: [],
60+
binds: [],
61+
env: {},
62+
},
63+
ssh: createSandboxSshConfig("/tmp/openclaw-sandboxes"),
64+
browser: createSandboxBrowserConfig(),
65+
tools: { allow: ["*"], deny: [] },
66+
prune: createSandboxPruneConfig(),
67+
};
68+
}
69+
70+
async function makeTempDir(prefix: string) {
71+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
72+
tempDirs.push(dir);
73+
return dir;
74+
}
75+
76+
describe("openshell backend exec workdir validation", () => {
77+
beforeEach(() => {
78+
vi.clearAllMocks();
79+
cliMocks.createOpenShellSshSession.mockResolvedValue({
80+
command: "ssh",
81+
configPath: "/tmp/openclaw-openshell-test-ssh-config",
82+
host: "openshell-test",
83+
});
84+
cliMocks.runOpenShellCli.mockResolvedValue({
85+
code: 0,
86+
stdout: "",
87+
stderr: "",
88+
});
89+
sdkMocks.runSshSandboxCommand.mockImplementation(async ({ remoteCommand }) => ({
90+
stdout: String(remoteCommand).includes("openclaw-validate-workdir")
91+
? Buffer.from("/workspace\n")
92+
: Buffer.alloc(0),
93+
stderr: Buffer.alloc(0),
94+
code: 0,
95+
}));
96+
});
97+
98+
afterEach(async () => {
99+
await Promise.all(
100+
tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
101+
);
102+
});
103+
104+
it("reuses validation-time workspace preparation for the following exec", async () => {
105+
const workspaceDir = await makeTempDir("openclaw-openshell-workspace-");
106+
await fs.writeFile(path.join(workspaceDir, "seed.txt"), "seed", "utf8");
107+
const backendFactory = createOpenShellSandboxBackendFactory({
108+
pluginConfig: resolveOpenShellPluginConfig({
109+
command: "openshell",
110+
mode: "mirror",
111+
}),
112+
});
113+
const backend = await backendFactory({
114+
sessionKey: "agent:main:turn",
115+
scopeKey: "agent:main",
116+
workspaceDir,
117+
agentWorkspaceDir: workspaceDir,
118+
cfg: createOpenShellBackendSandboxConfig(),
119+
});
120+
121+
await expect(backend.validateWorkdir?.("/workspace")).resolves.toBe("/workspace");
122+
const execSpec = await backend.buildExecSpec({
123+
command: "pwd",
124+
workdir: "/workspace",
125+
env: {},
126+
usePty: false,
127+
});
128+
129+
const uploadCalls = cliMocks.runOpenShellCli.mock.calls.filter(
130+
([params]) => params.args[0] === "sandbox" && params.args[1] === "upload",
131+
);
132+
expect(uploadCalls).toHaveLength(1);
133+
expect(execSpec.argv).toContain("openshell-test");
134+
});
135+
136+
it("does not reuse validation-time workspace preparation after discard", async () => {
137+
const workspaceDir = await makeTempDir("openclaw-openshell-workspace-");
138+
await fs.writeFile(path.join(workspaceDir, "seed.txt"), "seed", "utf8");
139+
const backendFactory = createOpenShellSandboxBackendFactory({
140+
pluginConfig: resolveOpenShellPluginConfig({
141+
command: "openshell",
142+
mode: "mirror",
143+
}),
144+
});
145+
const backend = await backendFactory({
146+
sessionKey: "agent:main:turn",
147+
scopeKey: "agent:main",
148+
workspaceDir,
149+
agentWorkspaceDir: workspaceDir,
150+
cfg: createOpenShellBackendSandboxConfig(),
151+
});
152+
153+
await expect(backend.validateWorkdir?.("/workspace")).resolves.toBe("/workspace");
154+
backend.discardPreparedWorkdir?.("/workspace");
155+
await backend.buildExecSpec({
156+
command: "pwd",
157+
workdir: "/workspace",
158+
env: {},
159+
usePty: false,
160+
});
161+
162+
const uploadCalls = cliMocks.runOpenShellCli.mock.calls.filter(
163+
([params]) => params.args[0] === "sandbox" && params.args[1] === "upload",
164+
);
165+
expect(uploadCalls).toHaveLength(2);
166+
});
167+
});

extensions/openshell/src/backend.ts

Lines changed: 99 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coer
2222
import type { OpenShellSandboxBackend } from "./backend.types.js";
2323
import {
2424
buildValidatedExecRemoteCommand,
25+
buildRemoteWorkdirValidationCommand,
2526
buildRemoteCommand,
2627
createOpenShellSshSession,
2728
runOpenShellCli,
@@ -280,6 +281,13 @@ async function createOpenShellSandboxBackend(params: {
280281
mode: params.pluginConfig.mode,
281282
configLabel: params.pluginConfig.from,
282283
configLabelKind: "Source",
284+
workdirValidation: "backend",
285+
validateWorkdir: async (workdir) => await impl.validateWorkdir(workdir),
286+
discardPreparedWorkdir: (workdir) => impl.discardPreparedWorkdir(workdir),
287+
workdirRoots: [
288+
params.pluginConfig.remoteWorkspaceDir,
289+
params.pluginConfig.remoteAgentWorkspaceDir,
290+
],
283291
buildExecSpec: async ({ command, workdir, env, usePty }) => {
284292
const pending = await impl.prepareExec({ command, workdir, env, usePty });
285293
return {
@@ -318,6 +326,10 @@ async function createOpenShellSandboxBackend(params: {
318326

319327
class OpenShellSandboxBackendImpl {
320328
private ensurePromise: Promise<void> | null = null;
329+
private preparedRemoteWorkspaceForNextExec: {
330+
workdir: string;
331+
promise: Promise<void>;
332+
} | null = null;
321333
private remoteSeedPending = false;
322334

323335
constructor(
@@ -339,6 +351,10 @@ class OpenShellSandboxBackendImpl {
339351
mode: this.params.execContext.config.mode,
340352
configLabel: this.params.execContext.config.from,
341353
configLabelKind: "Source",
354+
workdirValidation: "backend",
355+
validateWorkdir: async (workdir) => await this.validateWorkdir(workdir),
356+
discardPreparedWorkdir: (workdir) => this.discardPreparedWorkdir(workdir),
357+
workdirRoots: [this.params.remoteWorkspaceDir, this.params.remoteAgentWorkspaceDir],
342358
remoteWorkspaceDir: this.params.remoteWorkspaceDir,
343359
remoteAgentWorkspaceDir: this.params.remoteAgentWorkspaceDir,
344360
buildExecSpec: async ({ command, workdir, env, usePty }) => {
@@ -382,20 +398,14 @@ class OpenShellSandboxBackendImpl {
382398
env: Record<string, string>;
383399
usePty: boolean;
384400
}): Promise<{ argv: string[]; token: PendingExec }> {
401+
const remoteWorkdir = params.workdir ?? this.params.remoteWorkspaceDir;
402+
const preparedWorkspace = this.consumePreparedRemoteWorkspaceForNextExec(remoteWorkdir);
385403
const remoteCommand = buildValidatedExecRemoteCommand({
386404
command: params.command,
387-
workdir: params.workdir ?? this.params.remoteWorkspaceDir,
405+
workdir: remoteWorkdir,
388406
env: params.env,
389407
});
390-
await this.ensureSandboxExists();
391-
if (this.params.execContext.config.mode === "mirror") {
392-
await this.syncWorkspaceToRemote();
393-
} else {
394-
const seeded = await this.maybeSeedRemoteWorkspace();
395-
if (!seeded) {
396-
await this.syncSkillsWorkspaceToRemote();
397-
}
398-
}
408+
await (preparedWorkspace ?? this.prepareRemoteWorkspaceForExec());
399409
const sshSession = await createOpenShellSshSession({
400410
context: this.params.execContext,
401411
});
@@ -414,6 +424,85 @@ class OpenShellSandboxBackendImpl {
414424
};
415425
}
416426

427+
async validateWorkdir(workdir: string): Promise<string | null> {
428+
const preparedWorkspace = this.prepareRemoteWorkspaceForExec();
429+
const reusablePreparation = { workdir, promise: preparedWorkspace };
430+
this.preparedRemoteWorkspaceForNextExec = reusablePreparation;
431+
try {
432+
await preparedWorkspace;
433+
const sshSession = await createOpenShellSshSession({
434+
context: this.params.execContext,
435+
});
436+
try {
437+
const result = await runSshSandboxCommand({
438+
session: sshSession,
439+
remoteCommand: buildRemoteWorkdirValidationCommand({
440+
workdir,
441+
root: this.resolveWorkdirValidationRoot(workdir),
442+
}),
443+
allowFailure: true,
444+
});
445+
const resolvedWorkdir = result.code === 0 ? result.stdout.toString("utf8").trim() : "";
446+
if (this.preparedRemoteWorkspaceForNextExec === reusablePreparation) {
447+
this.preparedRemoteWorkspaceForNextExec = resolvedWorkdir
448+
? { workdir: resolvedWorkdir, promise: preparedWorkspace }
449+
: null;
450+
}
451+
return resolvedWorkdir || null;
452+
} finally {
453+
await disposeSshSandboxSession(sshSession);
454+
}
455+
} catch (error) {
456+
if (this.preparedRemoteWorkspaceForNextExec === reusablePreparation) {
457+
this.preparedRemoteWorkspaceForNextExec = null;
458+
}
459+
throw error;
460+
}
461+
}
462+
463+
private resolveWorkdirValidationRoot(workdir: string): string {
464+
try {
465+
const normalized = normalizeRemotePath(workdir);
466+
const roots = [
467+
normalizeRemotePath(this.params.remoteAgentWorkspaceDir),
468+
normalizeRemotePath(this.params.remoteWorkspaceDir),
469+
].toSorted((a, b) => b.length - a.length);
470+
return (
471+
roots.find((root) => isRemotePathInside(root, normalized)) ?? this.params.remoteWorkspaceDir
472+
);
473+
} catch {
474+
return this.params.remoteWorkspaceDir;
475+
}
476+
}
477+
478+
private consumePreparedRemoteWorkspaceForNextExec(workdir: string): Promise<void> | null {
479+
const preparedWorkspace = this.preparedRemoteWorkspaceForNextExec;
480+
if (!preparedWorkspace || preparedWorkspace.workdir !== workdir) {
481+
this.preparedRemoteWorkspaceForNextExec = null;
482+
return null;
483+
}
484+
this.preparedRemoteWorkspaceForNextExec = null;
485+
return preparedWorkspace.promise;
486+
}
487+
488+
discardPreparedWorkdir(workdir: string): void {
489+
if (this.preparedRemoteWorkspaceForNextExec?.workdir === workdir) {
490+
this.preparedRemoteWorkspaceForNextExec = null;
491+
}
492+
}
493+
494+
private async prepareRemoteWorkspaceForExec(): Promise<void> {
495+
await this.ensureSandboxExists();
496+
if (this.params.execContext.config.mode === "mirror") {
497+
await this.syncWorkspaceToRemote();
498+
return;
499+
}
500+
const seeded = await this.maybeSeedRemoteWorkspace();
501+
if (!seeded) {
502+
await this.syncSkillsWorkspaceToRemote();
503+
}
504+
}
505+
417506
async finalizeExec(token?: PendingExec): Promise<void> {
418507
try {
419508
if (this.params.execContext.config.mode === "mirror") {

extensions/openshell/src/cli.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { ResolvedOpenShellPluginConfig } from "./config.js";
99

1010
export {
1111
buildExecRemoteCommand,
12+
buildRemoteWorkdirValidationCommand,
1213
buildValidatedExecRemoteCommand,
1314
shellEscape,
1415
} from "openclaw/plugin-sdk/sandbox";

scripts/lib/plugin-sdk-doc-metadata.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ export const pluginSdkDocMetadata = {
2121
health: {
2222
category: "core",
2323
},
24+
sandbox: {
25+
category: "runtime",
26+
},
2427
"approval-runtime": {
2528
category: "runtime",
2629
},

scripts/plugin-sdk-surface-report.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,8 +202,8 @@ let publicDeprecatedExportsByEntrypointBudget;
202202
try {
203203
budgets = {
204204
publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 322),
205-
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10382),
206-
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5211),
205+
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10386),
206+
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5212),
207207
publicDeprecatedExports: readBudgetEnv(
208208
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",
209209
3247,

0 commit comments

Comments
 (0)