diff --git a/cmd/vscode-bridge/main.go b/cmd/vscode-bridge/main.go
new file mode 100644
index 0000000..5f35ba0
--- /dev/null
+++ b/cmd/vscode-bridge/main.go
@@ -0,0 +1,331 @@
+// Command vscode-bridge exposes the Nexus backend to VS Code extensions over a
+// localhost-only JSON-RPC and event stream.
+package main
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "log"
+ "net"
+ "net/http"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+
+ "github.com/andydixon/nexus/internal/api"
+ "github.com/andydixon/nexus/internal/audit"
+ "github.com/andydixon/nexus/internal/config"
+ "github.com/andydixon/nexus/internal/preferences"
+ "github.com/andydixon/nexus/internal/recording"
+ "github.com/andydixon/nexus/internal/secrets"
+ "github.com/andydixon/nexus/internal/sessions"
+ "github.com/andydixon/nexus/internal/storage"
+)
+
+type rpcRequest struct {
+ Method string `json:"method"`
+ Args []json.RawMessage `json:"args"`
+}
+
+type rpcResponse struct {
+ Result any `json:"result,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+type runtimeEventRequest struct {
+ Name string `json:"name"`
+ Payload map[string]any `json:"payload"`
+}
+
+type eventBroker struct {
+ mu sync.Mutex
+ clients map[chan []byte]struct{}
+}
+
+func newEventBroker() *eventBroker {
+ return &eventBroker{clients: make(map[chan []byte]struct{})}
+}
+
+func (b *eventBroker) publish(name string, payload any) {
+ msg, err := json.Marshal(map[string]any{"name": name, "payload": payload})
+ if err != nil {
+ return
+ }
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ for ch := range b.clients {
+ select {
+ case ch <- msg:
+ default:
+ }
+ }
+}
+
+func (b *eventBroker) subscribe() chan []byte {
+ ch := make(chan []byte, 64)
+ b.mu.Lock()
+ b.clients[ch] = struct{}{}
+ b.mu.Unlock()
+ return ch
+}
+
+func (b *eventBroker) unsubscribe(ch chan []byte) {
+ b.mu.Lock()
+ delete(b.clients, ch)
+ close(ch)
+ b.mu.Unlock()
+}
+
+type bridgeApp struct {
+ service *api.Service
+ manager *sessions.Manager
+ store *storage.Store
+ secrets *secrets.SecretStore
+ events *eventBroker
+}
+
+func main() {
+ listenAddr := flag.String("listen", "127.0.0.1:0", "address for the localhost bridge")
+ flag.Parse()
+
+ bridge, err := newBridgeApp()
+ if err != nil {
+ log.Fatalf("initialise bridge: %v", err)
+ }
+ defer bridge.close()
+
+ token, err := randomToken()
+ if err != nil {
+ log.Fatalf("create auth token: %v", err)
+ }
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("/health", bridge.auth(token, bridge.handleHealth))
+ mux.HandleFunc("/rpc", bridge.auth(token, bridge.handleRPC))
+ mux.HandleFunc("/events", bridge.auth(token, bridge.handleEvents))
+ mux.HandleFunc("/runtime/event", bridge.auth(token, bridge.handleRuntimeEvent))
+
+ server := &http.Server{Handler: mux}
+ listener, err := net.Listen("tcp", *listenAddr)
+ if err != nil {
+ log.Fatalf("listen: %v", err)
+ }
+
+ fmt.Fprintf(os.Stdout, "{\"addr\":%q,\"token\":%q}\n", listener.Addr().String(), token)
+
+ go func() {
+ if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ log.Printf("server error: %v", err)
+ }
+ }()
+
+ sigCh := make(chan os.Signal, 1)
+ signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
+ <-sigCh
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ _ = server.Shutdown(ctx)
+}
+
+func newBridgeApp() (*bridgeApp, error) {
+ cfg, err := config.Load()
+ if err != nil {
+ return nil, err
+ }
+ if err := cfg.EnsureDirectories(); err != nil {
+ return nil, err
+ }
+ store, err := storage.NewStore(cfg.DBPath)
+ if err != nil {
+ return nil, err
+ }
+
+ secretStore := secrets.NewSecretStore(cfg.SecretPath)
+ keychainStore := secrets.NewKeychainStore("NexusTerminal")
+ auditLog := audit.NewLogger(store)
+ prefs := preferences.NewManager(store)
+ if err := prefs.Load(); err != nil {
+ log.Printf("warning: failed to load preferences: %v", err)
+ }
+
+ recordStore := recording.NewStore(filepath.Join(cfg.DataDir, "recordings"))
+ manager := sessions.NewManager(store, secretStore, keychainStore, auditLog, recordStore)
+ service := api.NewService(store, secretStore, keychainStore, manager, auditLog, prefs)
+ events := newEventBroker()
+ manager.SetEventSink(events.publish)
+ service.SetEventSink(events.publish)
+
+ return &bridgeApp{
+ service: service,
+ manager: manager,
+ store: store,
+ secrets: secretStore,
+ events: events,
+ }, nil
+}
+
+func (a *bridgeApp) close() {
+ if a.manager != nil {
+ a.manager.CloseAll()
+ }
+ if a.store != nil {
+ _ = a.store.Close()
+ }
+ if a.secrets != nil {
+ a.secrets.Lock()
+ }
+}
+
+func (a *bridgeApp) auth(token string, next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Authorization") != "Bearer "+token {
+ http.Error(w, "unauthorised", http.StatusUnauthorized)
+ return
+ }
+ next(w, r)
+ }
+}
+
+func (a *bridgeApp) handleHealth(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(w, map[string]any{"ok": true})
+}
+
+func (a *bridgeApp) handleRPC(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ var req rpcRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeJSON(w, rpcResponse{Error: err.Error()})
+ return
+ }
+ result, err := callService(a.service, req.Method, req.Args)
+ if err != nil {
+ writeJSON(w, rpcResponse{Error: err.Error()})
+ return
+ }
+ writeJSON(w, rpcResponse{Result: result})
+}
+
+func (a *bridgeApp) handleRuntimeEvent(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ var req runtimeEventRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeJSON(w, rpcResponse{Error: err.Error()})
+ return
+ }
+ switch req.Name {
+ case "session:secret:response":
+ requestID, _ := req.Payload["requestID"].(string)
+ value, _ := req.Payload["value"].(string)
+ cancelled, _ := req.Payload["cancelled"].(bool)
+ a.service.RespondSecret(requestID, value, cancelled)
+ a.manager.RespondSecret(requestID, value)
+ case "session:hostkey:response":
+ requestID, _ := req.Payload["requestID"].(string)
+ accepted, _ := req.Payload["accepted"].(bool)
+ a.manager.RespondHostKey(requestID, accepted)
+ default:
+ writeJSON(w, rpcResponse{Error: "unsupported runtime event"})
+ return
+ }
+ writeJSON(w, rpcResponse{Result: true})
+}
+
+func (a *bridgeApp) handleEvents(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Connection", "keep-alive")
+
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ http.Error(w, "streaming unsupported", http.StatusInternalServerError)
+ return
+ }
+
+ ch := a.events.subscribe()
+ defer a.events.unsubscribe(ch)
+ fmt.Fprint(w, ": connected\n\n")
+ flusher.Flush()
+
+ for {
+ select {
+ case <-r.Context().Done():
+ return
+ case msg := <-ch:
+ fmt.Fprintf(w, "data: %s\n\n", msg)
+ flusher.Flush()
+ }
+ }
+}
+
+func callService(service *api.Service, methodName string, rawArgs []json.RawMessage) (any, error) {
+ if strings.TrimSpace(methodName) == "" {
+ return nil, errors.New("method is required")
+ }
+ method := reflect.ValueOf(service).MethodByName(methodName)
+ if !method.IsValid() {
+ return nil, fmt.Errorf("unknown service method %q", methodName)
+ }
+ mt := method.Type()
+ if len(rawArgs) != mt.NumIn() {
+ return nil, fmt.Errorf("%s expects %d args, got %d", methodName, mt.NumIn(), len(rawArgs))
+ }
+
+ args := make([]reflect.Value, mt.NumIn())
+ for i := 0; i < mt.NumIn(); i++ {
+ target := reflect.New(mt.In(i))
+ if err := json.Unmarshal(rawArgs[i], target.Interface()); err != nil {
+ return nil, fmt.Errorf("arg %d: %w", i, err)
+ }
+ args[i] = target.Elem()
+ }
+
+ values := method.Call(args)
+ if len(values) == 0 {
+ return true, nil
+ }
+ last := values[len(values)-1]
+ if last.Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) && !last.IsNil() {
+ return nil, last.Interface().(error)
+ }
+ if len(values) == 1 {
+ if last.Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) {
+ return true, nil
+ }
+ return last.Interface(), nil
+ }
+ return values[0].Interface(), nil
+}
+
+func randomToken() (string, error) {
+ var b [32]byte
+ if _, err := rand.Read(b[:]); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(b[:]), nil
+}
+
+func writeJSON(w http.ResponseWriter, value any) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(value)
+}
diff --git a/internal/api/api.go b/internal/api/api.go
index 6d7a308..6a63e69 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -61,6 +61,7 @@ type Service struct {
auditLog *audit.Logger
prefs *preferences.Manager
ctx context.Context
+ eventSink func(string, interface{})
// secretChans maps request IDs to channels awaiting user secret input.
secretMu sync.Mutex
@@ -107,6 +108,12 @@ func (s *Service) SetContext(ctx context.Context) {
s.RegisterSecretResponseListener()
}
+// SetEventSink registers an alternate UI event sink for non-Wails hosts such
+// as the VS Code bridge.
+func (s *Service) SetEventSink(sink func(string, interface{})) {
+ s.eventSink = sink
+}
+
// ---------------------------------------------------------------------------
// Profile operations
// ---------------------------------------------------------------------------
@@ -1026,6 +1033,26 @@ func (s *Service) DownloadGitHubRepoArchive(sessionID string, fullName string, r
return path, nil
}
+// DownloadGitHubRepoArchiveToPath downloads a repository zip archive to an
+// explicit local destination. Non-Wails hosts provide the save path through
+// their own file picker.
+func (s *Service) DownloadGitHubRepoArchiveToPath(sessionID string, fullName string, ref string, destPath string) (string, error) {
+ if strings.TrimSpace(fullName) == "" {
+ return "", fmt.Errorf("repository name cannot be empty")
+ }
+ destPath = strings.TrimSpace(destPath)
+ if destPath == "" {
+ return "", fmt.Errorf("destination path cannot be empty")
+ }
+ if filepath.Ext(destPath) != ".zip" {
+ destPath += ".zip"
+ }
+ if err := s.sessions.DownloadGitHubArchive(sessionID, fullName, ref, destPath); err != nil {
+ return "", err
+ }
+ return destPath, nil
+}
+
// ---------------------------------------------------------------------------
// VNC
// ---------------------------------------------------------------------------
@@ -1410,6 +1437,19 @@ func (s *Service) SFTPDownload(sessionID string, remotePath string) error {
return s.sessions.SFTPDownload(sessionID, remotePath, localPath)
}
+// SFTPDownloadToPath downloads a remote file to an explicit local path. This
+// is used by non-Wails hosts, where the UI owns the save dialog.
+func (s *Service) SFTPDownloadToPath(sessionID string, remotePath string, localPath string) error {
+ if strings.TrimSpace(remotePath) == "" {
+ return fmt.Errorf("remote path cannot be empty")
+ }
+ localPath = strings.TrimSpace(localPath)
+ if localPath == "" {
+ return fmt.Errorf("local path cannot be empty")
+ }
+ return s.sessions.SFTPDownload(sessionID, remotePath, localPath)
+}
+
// SFTPUpload uploads a local file to a remote directory. Opens a native file
// dialog to select the local file.
func (s *Service) SFTPUpload(sessionID string, remotePath string) error {
@@ -1431,6 +1471,29 @@ func (s *Service) SFTPUpload(sessionID string, remotePath string) error {
return s.sessions.SFTPUpload(sessionID, localPath, destPath)
}
+// SFTPUploadPath uploads an explicit local file path to a remote directory.
+// The remote filename is derived from the local basename and sanitised through
+// the same upload-name validation used by browser uploads.
+func (s *Service) SFTPUploadPath(sessionID string, remoteDir string, localPath string) error {
+ localPath = strings.TrimSpace(localPath)
+ if localPath == "" {
+ return fmt.Errorf("local path cannot be empty")
+ }
+ info, err := os.Stat(localPath)
+ if err != nil {
+ return fmt.Errorf("reading local file metadata: %w", err)
+ }
+ if info.IsDir() {
+ return fmt.Errorf("local path is a directory")
+ }
+ safeName, err := safeSFTPUploadName(filepath.Base(localPath))
+ if err != nil {
+ return err
+ }
+ destPath := path.Join(remoteDir, safeName)
+ return s.sessions.SFTPUpload(sessionID, localPath, destPath)
+}
+
// SFTPUploadData uploads raw file data to a remote directory.
// Data is expected as base64-encoded bytes from the frontend.
func (s *Service) SFTPUploadData(sessionID string, remoteDir string, fileName string, base64Data string) error {
@@ -1676,7 +1739,7 @@ func (s *Service) TestConnection(host string, port int) (map[string]interface{},
// This bridges the synchronous Go SSH auth callback with the asynchronous
// frontend prompt UI.
func (s *Service) promptSecret(prompt string) (string, error) {
- if s.ctx == nil {
+ if s.ctx == nil && s.eventSink == nil {
return "", fmt.Errorf("application context not available")
}
@@ -1693,7 +1756,7 @@ func (s *Service) promptSecret(prompt string) (string, error) {
s.secretMu.Unlock()
// Emit the prompt event to the frontend.
- wailsRuntime.EventsEmit(s.ctx, "session:secret:prompt", map[string]interface{}{
+ s.emitEvent("session:secret:prompt", map[string]interface{}{
"requestID": requestID,
"message": prompt,
"title": "Credentials Required",
@@ -1751,6 +1814,26 @@ func (s *Service) RegisterSecretResponseListener() {
})
}
+// RespondSecret supplies a credential prompt response for non-Wails hosts.
+func (s *Service) RespondSecret(requestID string, value string, cancelled bool) {
+ s.secretMu.Lock()
+ ch, exists := s.secretChans[requestID]
+ s.secretMu.Unlock()
+
+ if exists {
+ ch <- secretResponse{value: value, cancelled: cancelled}
+ }
+}
+
+func (s *Service) emitEvent(eventName string, data interface{}) {
+ if s.eventSink != nil {
+ s.eventSink(eventName, data)
+ }
+ if s.ctx != nil {
+ wailsRuntime.EventsEmit(s.ctx, eventName, data)
+ }
+}
+
// derefProfiles converts a slice of profile pointers to values.
func derefProfiles(ptrs []*models.Profile) []models.Profile {
result := make([]models.Profile, len(ptrs))
diff --git a/internal/api/leviathan.go b/internal/api/leviathan.go
index 0facc2a..b67d171 100644
--- a/internal/api/leviathan.go
+++ b/internal/api/leviathan.go
@@ -218,6 +218,23 @@ func (s *Service) LeviathanSaveWorking() (string, error) {
return path, writeLeviathanDocumentFile(path, doc)
}
+// LeviathanSaveWorkingToPath writes the patched bytes to an explicit path.
+// This is used by non-Wails hosts, where file picking is owned by the host UI.
+func (s *Service) LeviathanSaveWorkingToPath(path string) (string, error) {
+ engine := s.leviathanEngine()
+ engine.mu.Lock()
+ if engine.doc == nil {
+ engine.mu.Unlock()
+ return "", errors.New("open a file first")
+ }
+ doc := engine.doc
+ engine.mu.Unlock()
+ if strings.TrimSpace(path) == "" {
+ return "", errors.New("save path is required")
+ }
+ return path, writeLeviathanDocumentFile(path, doc)
+}
+
// LeviathanExportJSON writes a tool result to disk.
func (s *Service) LeviathanExportJSON(name string, value map[string]any) (string, error) {
if s.ctx == nil {
diff --git a/internal/sessions/manager.go b/internal/sessions/manager.go
index be28446..89ad15a 100644
--- a/internal/sessions/manager.go
+++ b/internal/sessions/manager.go
@@ -70,9 +70,12 @@ type Manager struct {
auditLog *audit.Logger
recordStore *recording.Store
ctx context.Context // Wails app context for event emission
+ eventSink func(string, interface{})
hostKeyMu sync.Mutex
hostKeyChans map[string]chan bool
hostKeyListenerRegistered bool
+ secretMu sync.Mutex
+ secretChans map[string]chan string
}
// NewManager creates a Manager wired to the given backing services.
@@ -93,6 +96,16 @@ func (m *Manager) SetContext(ctx context.Context) {
m.registerHostKeyResponseListener()
}
+// SetEventSink registers an alternate UI event sink for non-Wails hosts.
+// Wails remains supported through SetContext; this hook is used by the VS Code
+// bridge so existing protocol/session code can be reused without a webview
+// runtime context.
+func (m *Manager) SetEventSink(sink func(string, interface{})) {
+ m.mu.Lock()
+ m.eventSink = sink
+ m.mu.Unlock()
+}
+
// Connect creates a new session from a profile and connects it.
// The getSecret callback is used to prompt the user for credentials when
// required. Returns the session ID on success.
@@ -1706,7 +1719,7 @@ func (m *Manager) hostKeyCallback(sessionID string, policy models.HostKeyPolicy)
}
func (m *Manager) promptHostKey(sessionID string, host string, keyType string, fingerprint string) (bool, error) {
- if m.ctx == nil {
+ if m.ctx == nil && m.eventSink == nil {
return false, fmt.Errorf("host key prompt unavailable")
}
@@ -1784,6 +1797,20 @@ func (m *Manager) handleHostKeyResponse(optionalData ...interface{}) {
}
}
+// RespondHostKey supplies a host-key prompt response for non-Wails hosts.
+func (m *Manager) RespondHostKey(requestID string, accepted bool) {
+ m.hostKeyMu.Lock()
+ ch, exists := m.hostKeyChans[requestID]
+ m.hostKeyMu.Unlock()
+
+ if exists {
+ select {
+ case ch <- accepted:
+ default:
+ }
+ }
+}
+
// buildSecretCallback returns a func() (string, error) that resolves a
// credential according to the profile's SecretMode:
// - "saved": check keychain/vault first, prompt if missing, then persist
@@ -2240,25 +2267,30 @@ func (m *Manager) scheduleReconnect(sess *Session, sessionID string) {
// original callback is no longer available.
func (m *Manager) buildInteractiveSecretCallback(sessionID string) func(prompt string) (string, error) {
return func(prompt string) (string, error) {
- // Use the same event-based flow as the initial connection.
requestID := uuid.New().String()
ch := make(chan string, 1)
- m.mu.Lock()
- // Store a temporary secret channel keyed by requestID.
- if m.ctx != nil {
- wailsRuntime.EventsEmit(m.ctx, "session:secret:prompt", map[string]interface{}{
- "requestID": requestID,
- "sessionID": sessionID,
- "title": "Reconnection Credentials",
- "message": prompt,
- })
+ m.secretMu.Lock()
+ if m.secretChans == nil {
+ m.secretChans = make(map[string]chan string)
}
- m.mu.Unlock()
+ m.secretChans[requestID] = ch
+ m.secretMu.Unlock()
+ defer func() {
+ m.secretMu.Lock()
+ delete(m.secretChans, requestID)
+ m.secretMu.Unlock()
+ }()
+
+ m.emitEvent("session:secret:prompt", map[string]interface{}{
+ "requestID": requestID,
+ "sessionID": sessionID,
+ "title": "Reconnection Credentials",
+ "message": prompt,
+ })
// Wait for the response with a timeout.
var response string
- cancel := make(chan struct{})
if m.ctx != nil {
wailsRuntime.EventsOn(m.ctx, "session:secret:response", func(optionalData ...interface{}) {
@@ -2282,7 +2314,6 @@ func (m *Manager) buildInteractiveSecretCallback(sessionID string) func(prompt s
select {
case response = <-ch:
case <-time.After(60 * time.Second):
- close(cancel)
return "", fmt.Errorf("credential prompt timed out during reconnect")
}
@@ -2290,6 +2321,21 @@ func (m *Manager) buildInteractiveSecretCallback(sessionID string) func(prompt s
}
}
+// RespondSecret supplies a credential prompt response for reconnect prompts in
+// non-Wails hosts.
+func (m *Manager) RespondSecret(requestID string, value string) {
+ m.secretMu.Lock()
+ ch, exists := m.secretChans[requestID]
+ m.secretMu.Unlock()
+
+ if exists {
+ select {
+ case ch <- value:
+ default:
+ }
+ }
+}
+
// CancelReconnect cancels any in-progress auto-reconnect for a session.
func (m *Manager) CancelReconnect(sessionID string) {
m.mu.Lock()
@@ -2324,6 +2370,12 @@ func (m *Manager) createTabName(profile *models.Profile) string {
// emitEvent sends an event to the Wails frontend via the runtime context.
func (m *Manager) emitEvent(eventName string, data interface{}) {
+ m.mu.RLock()
+ sink := m.eventSink
+ m.mu.RUnlock()
+ if sink != nil {
+ sink(eventName, data)
+ }
if m.ctx == nil {
return
}
diff --git a/vscode/bin/nexus-vscode-bridge b/vscode/bin/nexus-vscode-bridge
new file mode 100755
index 0000000..e52950b
Binary files /dev/null and b/vscode/bin/nexus-vscode-bridge differ
diff --git a/vscode/build.sh b/vscode/build.sh
new file mode 100755
index 0000000..fd29cc1
--- /dev/null
+++ b/vscode/build.sh
@@ -0,0 +1,57 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+VSCODE_DIR="$ROOT_DIR/vscode"
+OUT_DIR="$VSCODE_DIR/dist"
+
+cd "$ROOT_DIR"
+
+echo "[vscode] Building frontend assets"
+if [ ! -d "$ROOT_DIR/frontend/node_modules" ]; then
+ npm --prefix "$ROOT_DIR/frontend" install
+fi
+npm --prefix "$ROOT_DIR/frontend" run build
+
+echo "[vscode] Building Go bridge"
+mkdir -p "$VSCODE_DIR/bin"
+BRIDGE_NAME="nexus-vscode-bridge"
+if [ "$(go env GOOS)" = "windows" ]; then
+ BRIDGE_NAME="nexus-vscode-bridge.exe"
+fi
+go build -trimpath -o "$VSCODE_DIR/bin/$BRIDGE_NAME" ./cmd/vscode-bridge
+
+echo "[vscode] Staging extension assets"
+for EXT in nexus leviathan; do
+ mkdir -p "$VSCODE_DIR/$EXT/bin"
+ cp "$VSCODE_DIR/bin/$BRIDGE_NAME" "$VSCODE_DIR/$EXT/bin/$BRIDGE_NAME"
+done
+
+cp "$ROOT_DIR/frontend/dist/src/js/lib/xterm.min.js" "$VSCODE_DIR/nexus/media/xterm.min.js"
+cp "$ROOT_DIR/frontend/dist/src/js/lib/xterm-addon-fit.min.js" "$VSCODE_DIR/nexus/media/xterm-addon-fit.min.js"
+cp "$ROOT_DIR/frontend/dist/src/css/lib/xterm.css" "$VSCODE_DIR/nexus/media/xterm.css"
+cp "$ROOT_DIR/frontend/dist/src/js/lib/novnc.min.js" "$VSCODE_DIR/nexus/media/novnc.min.js"
+
+echo "[vscode] Packaging VSIX-compatible archives"
+rm -rf "$OUT_DIR"
+mkdir -p "$OUT_DIR"
+
+package_extension() {
+ local ext="$1"
+ local out="$2"
+ if command -v vsce >/dev/null 2>&1; then
+ vsce package --no-dependencies --allow-missing-repository -o "$out"
+ else
+ npx --yes @vscode/vsce package --no-dependencies --allow-missing-repository -o "$out"
+ fi
+}
+
+for EXT in nexus leviathan; do
+ (
+ cd "$VSCODE_DIR/$EXT"
+ package_extension "$EXT" "$OUT_DIR/$EXT-0.1.0.vsix"
+ )
+ echo "[vscode] Wrote $OUT_DIR/$EXT-0.1.0.vsix"
+done
+
+echo "[vscode] Build complete"
diff --git a/vscode/leviathan/bin/nexus-vscode-bridge b/vscode/leviathan/bin/nexus-vscode-bridge
new file mode 100755
index 0000000..e52950b
Binary files /dev/null and b/vscode/leviathan/bin/nexus-vscode-bridge differ
diff --git a/vscode/leviathan/extension.js b/vscode/leviathan/extension.js
new file mode 100644
index 0000000..e6c5d88
--- /dev/null
+++ b/vscode/leviathan/extension.js
@@ -0,0 +1,948 @@
+'use strict';
+
+const vscode = require('vscode');
+const cp = require('child_process');
+const fs = require('fs');
+const http = require('http');
+const path = require('path');
+
+const TOOLS = [
+ ['Editor', 'hex', 'Hex Editor', 'Browse and patch bytes in the working copy.'],
+ ['Triage', 'overview', 'Overview', 'Fast first-pass summary across hashes, strings, entropy and IOCs.'],
+ ['Triage', 'hashes', 'Hashes', 'CRC32, MD5, SHA-1, SHA-256 and SHA-512.'],
+ ['Triage', 'entropy', 'Entropy', 'Windowed entropy for packed or encrypted regions.'],
+ ['Triage', 'strings', 'Strings', 'ASCII and UTF-16 string extraction.'],
+ ['Triage', 'iocs', 'IOCs', 'URLs, domains, hashes, paths and indicators.'],
+ ['Find', 'search', 'Search', 'Text, hex or regular-expression search.'],
+ ['Find', 'multi-search', 'Multi-search', 'Batch text, hex and regex searches.'],
+ ['Structure', 'parse', 'Structures', 'PE, ELF, Mach-O, ZIP, images, RIFF, ASN.1 and certificates.'],
+ ['Structure', 'disasm', 'Disasm', 'Heuristic disassembly for common CPU families.'],
+ ['Structure', 'pdf', 'PDF', 'Objects, streams, xrefs and suspicious PDF surface.'],
+ ['Structure', 'squashfs', 'SquashFS', 'Superblock and directory metadata.'],
+ ['Audio', 'spectrogram-encode', 'Spectrogram Encode', 'Convert images into WAV spectrogram payloads.'],
+ ['Audio', 'spectrogram-view', 'Spectrogram View', 'Render audio files as spectrogram images.'],
+ ['Compare', 'diff', 'Diff', 'Changed byte regions against a compare file.'],
+ ['Recover', 'carve', 'Carve', 'Embedded file carving candidates.'],
+ ['Recover', 'keys', 'Keys', 'PEM, DER, OpenSSH and PKCS#12 artefact discovery.'],
+ ['Recover', 'timestamps', 'Timestamps', 'UNIX, FILETIME, WebKit and OLE timestamp heuristics.'],
+ ['Recover', 'stego', 'Stego', 'Appended payloads and embedded trailer formats.'],
+ ['Recover', 'metadata', 'Metadata', 'PNG, JPEG and GIF textual metadata extraction.'],
+ ['Advanced', 'binsnoop', 'Binsnoop', 'Deep signature scanning through Binsnoop.'],
+ ['Advanced', 'rules', 'Rules', 'YARA-like rule scans over the current document.'],
+ ['Edit', 'patch-plan', 'Patch Plan', 'Validate patch scripts without modifying bytes.'],
+ ['Edit', 'patch-apply', 'Patch Apply', 'Apply a patch script to the working copy.'],
+ ['Edit', 'transform', 'Transforms', 'Hex, Base64, URL, XOR, inflate and cipher pipelines.'],
+ ['Case', 'case', 'Case Export', 'Build a case archive payload from the working copy.']
+].map(([group, id, name, desc]) => ({ group, id, name, desc }));
+
+const DEFAULTS = {
+ search: { mode: 'text', query: 'MZ', max: 1000 },
+ 'multi-search': { spec: 'text MZ\nhex 7f454c46\nregex https?://', max: 1000 },
+ parse: { kind: 'auto' },
+ disasm: { arch: 'x86_64', start: 0, length: 4096, max: 1000 },
+ entropy: { window: 4096, step: 4096 },
+ strings: { min: 4, max: 5000 },
+ iocs: { min: 5 },
+ carve: { max: 256 },
+ keys: { max: 200 },
+ timestamps: { max: 1000 },
+ rules: { source: 'rule mz { strings: $a = "MZ" condition: $a }', max: 1000 },
+ 'patch-plan': { source: 'set 0x00 90 90' },
+ 'patch-apply': { source: 'set 0x00 90 90' },
+ transform: { pipeline: 'hex.encode', start: 0, length: 1024 },
+ 'spectrogram-view': { fftSize: 2048, height: 1024, maxWidth: 1400, aspectRatio: 1, minHz: 0, maxHz: 0, brightness: 1, contrast: 1 },
+ hex: { start: 0, bytes: 2048, width: 16 }
+};
+
+class Bridge {
+ constructor(context) {
+ this.context = context;
+ this.proc = null;
+ this.addr = null;
+ this.token = null;
+ }
+
+ async start() {
+ if (this.addr) return;
+ const exe = process.platform === 'win32' ? 'nexus-vscode-bridge.exe' : 'nexus-vscode-bridge';
+ const bridgePath = path.join(this.context.extensionPath, 'bin', exe);
+ if (!fs.existsSync(bridgePath)) {
+ throw new Error(`Bridge executable not found: ${bridgePath}. Run vscode/build.sh first.`);
+ }
+ this.proc = cp.spawn(bridgePath, ['-listen', '127.0.0.1:0'], {
+ stdio: ['ignore', 'pipe', 'pipe'],
+ windowsHide: true
+ });
+ this.context.subscriptions.push({ dispose: () => this.stop() });
+ const firstLine = await new Promise((resolve, reject) => {
+ let buf = '';
+ const timer = setTimeout(() => reject(new Error('Timed out waiting for bridge startup')), 10000);
+ this.proc.stdout.on('data', chunk => {
+ buf += chunk.toString('utf8');
+ const idx = buf.indexOf('\n');
+ if (idx >= 0) {
+ clearTimeout(timer);
+ resolve(buf.slice(0, idx));
+ }
+ });
+ this.proc.once('error', reject);
+ this.proc.once('exit', code => reject(new Error(`Bridge exited during startup (${code})`)));
+ });
+ const meta = JSON.parse(firstLine);
+ this.addr = meta.addr;
+ this.token = meta.token;
+ }
+
+ stop() {
+ if (this.proc && !this.proc.killed) this.proc.kill();
+ this.proc = null;
+ }
+
+ async rpc(method, args = []) {
+ await this.start();
+ const payload = Buffer.from(JSON.stringify({ method, args }));
+ return new Promise((resolve, reject) => {
+ const req = http.request({
+ hostname: this.addr.split(':')[0],
+ port: Number(this.addr.split(':')[1]),
+ path: '/rpc',
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${this.token}`,
+ 'Content-Type': 'application/json',
+ 'Content-Length': payload.length
+ }
+ }, res => {
+ let data = '';
+ res.on('data', chunk => data += chunk);
+ res.on('end', () => {
+ if (res.statusCode < 200 || res.statusCode >= 300) {
+ reject(new Error(`Bridge HTTP ${res.statusCode}: ${data}`));
+ return;
+ }
+ const decoded = JSON.parse(data || '{}');
+ if (decoded.error) reject(new Error(decoded.error));
+ else resolve(decoded.result);
+ });
+ });
+ req.on('error', reject);
+ req.write(payload);
+ req.end();
+ });
+ }
+}
+
+class ToolsView {
+ constructor(context, bridge) {
+ this.context = context;
+ this.bridge = bridge;
+ this.view = null;
+ this.state = null;
+ }
+
+ resolveWebviewView(view) {
+ this.view = view;
+ view.webview.options = { enableScripts: true };
+ view.webview.html = this.html(view.webview);
+ view.webview.onDidReceiveMessage(async msg => {
+ try {
+ if (msg.type === 'load') await loadFile(this.bridge, false, this);
+ if (msg.type === 'compare') await loadFile(this.bridge, true, this);
+ if (msg.type === 'save') await saveFile(this.bridge, this);
+ if (msg.type === 'refresh') await this.refreshState();
+ if (msg.type === 'tool') await openTool(this.context, this.bridge, this, msg.tool);
+ } catch (err) {
+ this.postError(err);
+ }
+ });
+ this.refreshState();
+ }
+
+ async refreshState() {
+ try {
+ this.state = await this.bridge.rpc('LeviathanState');
+ } catch (_) {
+ this.state = null;
+ }
+ this.postState();
+ }
+
+ postState() {
+ if (this.view) this.view.webview.postMessage({ type: 'state', state: this.state });
+ }
+
+ postError(err) {
+ if (this.view) this.view.webview.postMessage({ type: 'error', error: err.message || String(err) });
+ else vscode.window.showErrorMessage(err.message || String(err));
+ }
+
+ html(webview) {
+ const nonce = String(Date.now());
+ const toolsJson = JSON.stringify(TOOLS);
+ return `
+
+
+
+
+ Load File
+ Compare
+ Save Copy
+ Refresh
+
+
No file loaded Load a binary or right-click a file in Explorer.
+
+
+
+
+
+
+
+`;
+ }
+}
+
+class ToolPanel {
+ constructor(context, bridge, tool, options = {}) {
+ this.context = context;
+ this.bridge = bridge;
+ this.tool = tool;
+ this.fileState = options.fileState || null;
+ const meta = TOOLS.find(t => t.id === tool) || { name: tool };
+ const title = this.fileState?.name ? `${meta.name}: ${this.fileState.name}` : meta.name;
+ this.panel = vscode.window.createWebviewPanel('leviathanTool', title, vscode.ViewColumn.Active, { enableScripts: true, retainContextWhenHidden: true });
+ this.panel.webview.html = this.tool === 'hex' ? this.hexHtml(meta) : this.html(meta);
+ this.panel.webview.onDidReceiveMessage(msg => {
+ if (msg.type === 'run') this.run(msg.params);
+ if (msg.type === 'hexLoad') this.loadHex(msg);
+ if (msg.type === 'hexSet') this.setHexByte(msg);
+ if (msg.type === 'hexSearch') this.searchHex(msg);
+ if (msg.type === 'undo') this.stateAction('LeviathanUndo', msg);
+ if (msg.type === 'redo') this.stateAction('LeviathanRedo', msg);
+ if (msg.type === 'revert') this.stateAction('LeviathanRevert', msg);
+ });
+ this.panel.webview.postMessage({ type: 'fileState', state: this.fileState });
+ this.refreshState();
+ if (options.autoRun) {
+ if (this.tool === 'hex') {
+ setTimeout(() => this.loadHex(DEFAULTS.hex), 150);
+ } else {
+ setTimeout(() => this.run(JSON.stringify(DEFAULTS[this.tool] || {})), 150);
+ }
+ }
+ }
+
+ async refreshState() {
+ try {
+ const state = await this.bridge.rpc('LeviathanState');
+ this.fileState = state;
+ this.panel.webview.postMessage({ type: 'fileState', state });
+ } catch (err) {
+ this.error(err);
+ }
+ }
+
+ async run(paramsText) {
+ try {
+ const params = JSON.parse(paramsText || '{}');
+ const result = await this.bridge.rpc('LeviathanRunTool', [this.tool, params]);
+ this.panel.webview.postMessage({ type: 'result', result });
+ } catch (err) {
+ this.error(err);
+ }
+ }
+
+ error(err) {
+ this.panel.webview.postMessage({ type: 'error', error: err.message || String(err) });
+ }
+
+ async loadHex(params = {}) {
+ try {
+ const start = Number(params.start || 0);
+ const bytes = Number(params.bytes || 2048);
+ const width = Number(params.width || 16);
+ const result = await this.bridge.rpc('LeviathanRunTool', ['hex', { start, bytes, width }]);
+ this.panel.webview.postMessage({ type: 'hexRows', rows: result.rows || [], start, bytes, width });
+ await this.refreshState();
+ } catch (err) {
+ this.error(err);
+ }
+ }
+
+ async setHexByte(msg) {
+ try {
+ const offset = Number(msg.offset);
+ const value = String(msg.value || '').trim();
+ const payload = await this.bridge.rpc('LeviathanSetByte', [offset, value]);
+ if (payload && payload.state) {
+ this.fileState = payload.state;
+ this.panel.webview.postMessage({ type: 'fileState', state: payload.state });
+ }
+ await this.loadHex({ start: msg.start, bytes: msg.bytes, width: msg.width });
+ } catch (err) {
+ this.panel.webview.postMessage({ type: 'hexEditError', offset: msg.offset, error: err.message || String(err) });
+ }
+ }
+
+ async searchHex(msg) {
+ try {
+ const mode = msg.mode === 'hex' ? 'hex' : 'text';
+ const query = String(msg.query || '');
+ const result = await this.bridge.rpc('LeviathanRunTool', ['search', { mode, query, max: 2000 }]);
+ this.panel.webview.postMessage({ type: 'hexSearchResults', hits: result.hits || [], mode, query });
+ } catch (err) {
+ this.error(err);
+ }
+ }
+
+ async stateAction(method, msg = {}) {
+ try {
+ const state = await this.bridge.rpc(method);
+ this.panel.webview.postMessage({ type: 'state', state });
+ this.panel.webview.postMessage({ type: 'fileState', state });
+ if (this.tool === 'hex') {
+ await this.loadHex(msg.start === undefined ? DEFAULTS.hex : msg);
+ }
+ } catch (err) {
+ this.error(err);
+ }
+ }
+
+ hexHtml(meta) {
+ const nonce = String(Date.now());
+ const initialFileState = JSON.stringify(this.fileState || null);
+ const name = escapeHtml(meta.name);
+ return `
+
+
+