@@ -22,6 +22,7 @@ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coer
2222import type { OpenShellSandboxBackend } from "./backend.types.js" ;
2323import {
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
319327class 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" ) {
0 commit comments