@@ -264,6 +264,47 @@ app.get("/setup", requireSetupAuth, (_req, res) => {
264264 |
265265 <a href="/setup/export" target="_blank">Download backup (.tar.gz)</a>
266266 </div>
267+
268+ <div style="margin-top: 0.75rem">
269+ <div class="muted" style="margin-bottom:0.25rem"><strong>Import backup</strong> (advanced): restores into <code>/data</code> and restarts the gateway.</div>
270+ <input id="importFile" type="file" accept=".tar.gz,application/gzip" />
271+ <button id="importRun" style="background:#7c2d12; margin-top:0.5rem">Import</button>
272+ <pre id="importOut" style="white-space:pre-wrap"></pre>
273+ </div>
274+ </div>
275+
276+ <div class="card">
277+ <h2>Debug console</h2>
278+ <p class="muted">Run a small allowlist of safe commands (no shell). Useful for debugging and recovery.</p>
279+
280+ <div style="display:flex; gap:0.5rem; align-items:center">
281+ <select id="consoleCmd" style="flex: 1">
282+ <option value="gateway.restart">gateway.restart (wrapper-managed)</option>
283+ <option value="gateway.stop">gateway.stop (wrapper-managed)</option>
284+ <option value="gateway.start">gateway.start (wrapper-managed)</option>
285+ <option value="openclaw.status">openclaw status</option>
286+ <option value="openclaw.health">openclaw health</option>
287+ <option value="openclaw.doctor">openclaw doctor</option>
288+ <option value="openclaw.logs.tail">openclaw logs --tail N</option>
289+ <option value="openclaw.config.get">openclaw config get <path></option>
290+ <option value="openclaw.version">openclaw --version</option>
291+ </select>
292+ <input id="consoleArg" placeholder="Optional arg (e.g. 200, gateway.port)" style="flex: 1" />
293+ <button id="consoleRun" style="background:#0f172a">Run</button>
294+ </div>
295+ <pre id="consoleOut" style="white-space:pre-wrap"></pre>
296+ </div>
297+
298+ <div class="card">
299+ <h2>Config editor (advanced)</h2>
300+ <p class="muted">Edits the full config file on disk (JSON5). Saving creates a timestamped <code>.bak-*</code> backup and restarts the gateway.</p>
301+ <div class="muted" id="configPath"></div>
302+ <textarea id="configText" style="width:100%; height: 260px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;"></textarea>
303+ <div style="margin-top:0.5rem">
304+ <button id="configReload" style="background:#1f2937">Reload</button>
305+ <button id="configSave" style="background:#111; margin-left:0.5rem">Save</button>
306+ </div>
307+ <pre id="configOut" style="white-space:pre-wrap"></pre>
267308 </div>
268309
269310 <div class="card">
@@ -604,6 +645,133 @@ app.get("/setup/api/debug", requireSetupAuth, async (_req, res) => {
604645 } ) ;
605646} ) ;
606647
648+ // --- Debug console (Option A: allowlisted commands + config editor) ---
649+
650+ function redactSecrets ( text ) {
651+ if ( ! text ) return text ;
652+ // Very small best-effort redaction. (Config paths/values may still contain secrets.)
653+ return String ( text )
654+ . replace ( / ( s k - [ A - Z a - z 0 - 9 _ - ] { 10 , } ) / g, "[REDACTED]" )
655+ . replace ( / ( g h o _ [ A - Z a - z 0 - 9 _ ] { 10 , } ) / g, "[REDACTED]" )
656+ . replace ( / ( x o x [ b a p r s ] - [ A - Z a - z 0 - 9 - ] { 10 , } ) / g, "[REDACTED]" )
657+ . replace ( / ( A A [ A - Z a - z 0 - 9 _ - ] { 10 , } : \S { 10 , } ) / g, "[REDACTED]" ) ;
658+ }
659+
660+ const ALLOWED_CONSOLE_COMMANDS = new Set ( [
661+ // Wrapper-managed lifecycle
662+ "gateway.restart" ,
663+ "gateway.stop" ,
664+ "gateway.start" ,
665+
666+ // OpenClaw CLI helpers
667+ "openclaw.version" ,
668+ "openclaw.status" ,
669+ "openclaw.health" ,
670+ "openclaw.doctor" ,
671+ "openclaw.logs.tail" ,
672+ "openclaw.config.get" ,
673+ ] ) ;
674+
675+ app . post ( "/setup/api/console/run" , requireSetupAuth , async ( req , res ) => {
676+ const payload = req . body || { } ;
677+ const cmd = String ( payload . cmd || "" ) . trim ( ) ;
678+ const arg = String ( payload . arg || "" ) . trim ( ) ;
679+
680+ if ( ! ALLOWED_CONSOLE_COMMANDS . has ( cmd ) ) {
681+ return res . status ( 400 ) . json ( { ok : false , error : "Command not allowed" } ) ;
682+ }
683+
684+ try {
685+ if ( cmd === "gateway.restart" ) {
686+ await restartGateway ( ) ;
687+ return res . json ( { ok : true , output : "Gateway restarted (wrapper-managed).\n" } ) ;
688+ }
689+ if ( cmd === "gateway.stop" ) {
690+ if ( gatewayProc ) {
691+ try { gatewayProc . kill ( "SIGTERM" ) ; } catch { }
692+ await sleep ( 750 ) ;
693+ gatewayProc = null ;
694+ }
695+ return res . json ( { ok : true , output : "Gateway stopped (wrapper-managed).\n" } ) ;
696+ }
697+ if ( cmd === "gateway.start" ) {
698+ const r = await ensureGatewayRunning ( ) ;
699+ return res . json ( { ok : Boolean ( r . ok ) , output : r . ok ? "Gateway started.\n" : `Gateway not started: ${ r . reason } \n` } ) ;
700+ }
701+
702+ if ( cmd === "openclaw.version" ) {
703+ const r = await runCmd ( OPENCLAW_NODE , clawArgs ( [ "--version" ] ) ) ;
704+ return res . status ( r . code === 0 ? 200 : 500 ) . json ( { ok : r . code === 0 , output : redactSecrets ( r . output ) } ) ;
705+ }
706+ if ( cmd === "openclaw.status" ) {
707+ const r = await runCmd ( OPENCLAW_NODE , clawArgs ( [ "status" ] ) ) ;
708+ return res . status ( r . code === 0 ? 200 : 500 ) . json ( { ok : r . code === 0 , output : redactSecrets ( r . output ) } ) ;
709+ }
710+ if ( cmd === "openclaw.health" ) {
711+ const r = await runCmd ( OPENCLAW_NODE , clawArgs ( [ "health" ] ) ) ;
712+ return res . status ( r . code === 0 ? 200 : 500 ) . json ( { ok : r . code === 0 , output : redactSecrets ( r . output ) } ) ;
713+ }
714+ if ( cmd === "openclaw.doctor" ) {
715+ const r = await runCmd ( OPENCLAW_NODE , clawArgs ( [ "doctor" ] ) ) ;
716+ return res . status ( r . code === 0 ? 200 : 500 ) . json ( { ok : r . code === 0 , output : redactSecrets ( r . output ) } ) ;
717+ }
718+ if ( cmd === "openclaw.logs.tail" ) {
719+ const lines = Math . max ( 50 , Math . min ( 1000 , Number . parseInt ( arg || "200" , 10 ) || 200 ) ) ;
720+ const r = await runCmd ( OPENCLAW_NODE , clawArgs ( [ "logs" , "--tail" , String ( lines ) ] ) ) ;
721+ return res . status ( r . code === 0 ? 200 : 500 ) . json ( { ok : r . code === 0 , output : redactSecrets ( r . output ) } ) ;
722+ }
723+ if ( cmd === "openclaw.config.get" ) {
724+ if ( ! arg ) return res . status ( 400 ) . json ( { ok : false , error : "Missing config path" } ) ;
725+ const r = await runCmd ( OPENCLAW_NODE , clawArgs ( [ "config" , "get" , arg ] ) ) ;
726+ return res . status ( r . code === 0 ? 200 : 500 ) . json ( { ok : r . code === 0 , output : redactSecrets ( r . output ) } ) ;
727+ }
728+
729+ return res . status ( 400 ) . json ( { ok : false , error : "Unhandled command" } ) ;
730+ } catch ( err ) {
731+ return res . status ( 500 ) . json ( { ok : false , error : String ( err ) } ) ;
732+ }
733+ } ) ;
734+
735+ app . get ( "/setup/api/config/raw" , requireSetupAuth , async ( _req , res ) => {
736+ try {
737+ const p = configPath ( ) ;
738+ const exists = fs . existsSync ( p ) ;
739+ const content = exists ? fs . readFileSync ( p , "utf8" ) : "" ;
740+ res . json ( { ok : true , path : p , exists, content } ) ;
741+ } catch ( err ) {
742+ res . status ( 500 ) . json ( { ok : false , error : String ( err ) } ) ;
743+ }
744+ } ) ;
745+
746+ app . post ( "/setup/api/config/raw" , requireSetupAuth , async ( req , res ) => {
747+ try {
748+ const content = String ( ( req . body && req . body . content ) || "" ) ;
749+ if ( content . length > 500_000 ) {
750+ return res . status ( 413 ) . json ( { ok : false , error : "Config too large" } ) ;
751+ }
752+
753+ fs . mkdirSync ( STATE_DIR , { recursive : true } ) ;
754+
755+ const p = configPath ( ) ;
756+ // Backup
757+ if ( fs . existsSync ( p ) ) {
758+ const backupPath = `${ p } .bak-${ new Date ( ) . toISOString ( ) . replace ( / [: .] / g, "-" ) } ` ;
759+ fs . copyFileSync ( p , backupPath ) ;
760+ }
761+
762+ fs . writeFileSync ( p , content , { encoding : "utf8" , mode : 0o600 } ) ;
763+
764+ // Apply immediately.
765+ if ( isConfigured ( ) ) {
766+ await restartGateway ( ) ;
767+ }
768+
769+ res . json ( { ok : true , path : p } ) ;
770+ } catch ( err ) {
771+ res . status ( 500 ) . json ( { ok : false , error : String ( err ) } ) ;
772+ }
773+ } ) ;
774+
607775app . post ( "/setup/api/pairing/approve" , requireSetupAuth , async ( req , res ) => {
608776 const { channel, code } = req . body || { } ;
609777 if ( ! channel || ! code ) {
@@ -674,6 +842,95 @@ app.get("/setup/export", requireSetupAuth, async (_req, res) => {
674842 stream . pipe ( res ) ;
675843} ) ;
676844
845+ function isUnderDir ( p , root ) {
846+ const abs = path . resolve ( p ) ;
847+ const r = path . resolve ( root ) ;
848+ return abs === r || abs . startsWith ( r + path . sep ) ;
849+ }
850+
851+ function looksSafeTarPath ( p ) {
852+ if ( ! p ) return false ;
853+ // tar paths always use / separators
854+ if ( p . startsWith ( "/" ) || p . startsWith ( "\\" ) ) return false ;
855+ // windows drive letters
856+ if ( / ^ [ A - Z a - z ] : [ \\ / ] / . test ( p ) ) return false ;
857+ // path traversal
858+ if ( p . split ( "/" ) . includes ( ".." ) ) return false ;
859+ return true ;
860+ }
861+
862+ async function readBodyBuffer ( req , maxBytes ) {
863+ return new Promise ( ( resolve , reject ) => {
864+ const chunks = [ ] ;
865+ let total = 0 ;
866+ req . on ( "data" , ( chunk ) => {
867+ total += chunk . length ;
868+ if ( total > maxBytes ) {
869+ reject ( new Error ( "payload too large" ) ) ;
870+ req . destroy ( ) ;
871+ return ;
872+ }
873+ chunks . push ( chunk ) ;
874+ } ) ;
875+ req . on ( "end" , ( ) => resolve ( Buffer . concat ( chunks ) ) ) ;
876+ req . on ( "error" , reject ) ;
877+ } ) ;
878+ }
879+
880+ // Import a backup created by /setup/export.
881+ // This is intentionally limited to restoring into /data to avoid overwriting arbitrary host paths.
882+ app . post ( "/setup/import" , requireSetupAuth , async ( req , res ) => {
883+ try {
884+ const dataRoot = "/data" ;
885+ if ( ! isUnderDir ( STATE_DIR , dataRoot ) || ! isUnderDir ( WORKSPACE_DIR , dataRoot ) ) {
886+ return res
887+ . status ( 400 )
888+ . type ( "text/plain" )
889+ . send ( "Import is only supported when OPENCLAW_STATE_DIR and OPENCLAW_WORKSPACE_DIR are under /data (Railway volume).\n" ) ;
890+ }
891+
892+ // Stop gateway before restore so we don't overwrite live files.
893+ if ( gatewayProc ) {
894+ try { gatewayProc . kill ( "SIGTERM" ) ; } catch { }
895+ await sleep ( 750 ) ;
896+ gatewayProc = null ;
897+ }
898+
899+ const buf = await readBodyBuffer ( req , 250 * 1024 * 1024 ) ; // 250MB max
900+ if ( ! buf . length ) return res . status ( 400 ) . type ( "text/plain" ) . send ( "Empty body\n" ) ;
901+
902+ // Extract into /data.
903+ // We only allow safe relative paths, and we intentionally do NOT delete existing files.
904+ // (Users can reset/redeploy or manually clean the volume if desired.)
905+ const tmpPath = path . join ( os . tmpdir ( ) , `openclaw-import-${ Date . now ( ) } .tar.gz` ) ;
906+ fs . writeFileSync ( tmpPath , buf ) ;
907+
908+ await tar . x ( {
909+ file : tmpPath ,
910+ cwd : dataRoot ,
911+ gzip : true ,
912+ strict : true ,
913+ onwarn : ( ) => { } ,
914+ filter : ( p ) => {
915+ // Allow only paths that look safe.
916+ return looksSafeTarPath ( p ) ;
917+ } ,
918+ } ) ;
919+
920+ try { fs . rmSync ( tmpPath , { force : true } ) ; } catch { }
921+
922+ // Restart gateway after restore.
923+ if ( isConfigured ( ) ) {
924+ await restartGateway ( ) ;
925+ }
926+
927+ res . type ( "text/plain" ) . send ( "OK - imported backup into /data and restarted gateway.\n" ) ;
928+ } catch ( err ) {
929+ console . error ( "[import]" , err ) ;
930+ res . status ( 500 ) . type ( "text/plain" ) . send ( String ( err ) ) ;
931+ }
932+ } ) ;
933+
677934// Proxy everything else to the gateway.
678935const proxy = httpProxy . createProxyServer ( {
679936 target : GATEWAY_TARGET ,
0 commit comments