diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d6f9eb6 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +LINGMA_PROXY_PORT=8095 +LINGMA_REMOTE_BASE_URL=https://lingma.alibabacloud.com +LINGMA_REMOTE_AUTH_FILE=/secrets/credentials.json + +LINGMA_BOOTSTRAP_ENABLED=true +LINGMA_SOURCE_TYPE=marketplace +LINGMA_VSIX_URL= +LINGMA_MARKETPLACE_PUBLISHER=Alibaba-Cloud +LINGMA_MARKETPLACE_EXTENSION=tongyi-lingma +LINGMA_BIN=/app/data/bin/Lingma +LINGMA_BOOTSTRAP_OUTPUT_DIR=/app/data/bin/release +LINGMA_BOOTSTRAP_ALWAYS=true +LINGMA_FORCE_REFRESH=false + +LINGMA_WORK_DIR=/app/data/.lingma/vscode/sharedClientCache +LINGMA_SESSION_BUNDLE= +LINGMA_SESSION_BUNDLE_FILE=/secrets/session.bundle + +LINGMA_PROXY_BACKEND=remote +LINGMA_PROXY_SESSION_MODE=auto +LINGMA_PROXY_MODEL=kmodel +LINGMA_PROXY_TIMEOUT_SECONDS=0 +LINGMA_REMOTE_FALLBACK_ENABLED=true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..02facaa --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM golang:1.23.6 AS build + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/lingma-proxy ./cmd/lingma-ipc-proxy + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates wget && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=build /out/lingma-proxy /usr/local/bin/lingma-proxy +EXPOSE 8095 +CMD ["lingma-proxy", "--host", "0.0.0.0", "--port", "8095", "--backend", "remote"] diff --git a/cmd/lingma-ipc-proxy/main.go b/cmd/lingma-ipc-proxy/main.go index 0431e36..a9d30ae 100644 --- a/cmd/lingma-ipc-proxy/main.go +++ b/cmd/lingma-ipc-proxy/main.go @@ -22,24 +22,36 @@ import ( ) type fileConfig struct { - Host string `json:"host"` - Port int `json:"port"` - Backend string `json:"backend"` - Transport string `json:"transport"` - Pipe string `json:"pipe"` - WebSocketURL string `json:"websocket_url"` - RemoteBaseURL string `json:"remote_base_url"` - RemoteAuthFile string `json:"remote_auth_file"` - RemoteVersion string `json:"remote_version"` - Cwd string `json:"cwd"` - CurrentFilePath string `json:"current_file_path"` - Mode string `json:"mode"` - Model string `json:"model"` - ShellType string `json:"shell_type"` - SessionMode string `json:"session_mode"` - TimeoutSeconds int `json:"timeout"` - RemoteFallbackEnabled *bool `json:"remote_fallback_enabled"` - RemoteFallbackModels []string `json:"remote_fallback_models"` + Host string `json:"host"` + Port int `json:"port"` + Backend string `json:"backend"` + Transport string `json:"transport"` + Pipe string `json:"pipe"` + WebSocketURL string `json:"websocket_url"` + RemoteBaseURL string `json:"remote_base_url"` + RemoteAuthFile string `json:"remote_auth_file"` + RemoteVersion string `json:"remote_version"` + Cwd string `json:"cwd"` + CurrentFilePath string `json:"current_file_path"` + Mode string `json:"mode"` + Model string `json:"model"` + ShellType string `json:"shell_type"` + SessionMode string `json:"session_mode"` + TimeoutSeconds int `json:"timeout"` + RemoteFallbackEnabled *bool `json:"remote_fallback_enabled"` + RemoteFallbackModels []string `json:"remote_fallback_models"` + LingmaBootstrapEnabled *bool `json:"lingma_bootstrap_enabled"` + LingmaSourceType string `json:"lingma_source_type"` + LingmaVSIXURL string `json:"lingma_vsix_url"` + LingmaMarketplacePublisher string `json:"lingma_marketplace_publisher"` + LingmaMarketplaceExtension string `json:"lingma_marketplace_extension"` + LingmaBootstrapOutputDir string `json:"lingma_bootstrap_output_dir"` + LingmaBinaryPath string `json:"lingma_binary_path"` + LingmaBootstrapAlways *bool `json:"lingma_bootstrap_always"` + LingmaForceRefresh *bool `json:"lingma_force_refresh"` + LingmaWorkDir string `json:"lingma_work_dir"` + LingmaSessionBundle string `json:"lingma_session_bundle"` + LingmaSessionBundleFile string `json:"lingma_session_bundle_file"` } func main() { @@ -47,6 +59,9 @@ func main() { addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) svc := service.New(cfg) + if err := svc.PrepareRuntime(); err != nil { + log.Fatalf("prepare runtime: %v", err) + } warmupCtx, warmupCancel := context.WithTimeout(context.Background(), 10*time.Second) if err := svc.Warmup(warmupCtx); err != nil { log.Printf("warmup failed: %v", err) @@ -91,18 +106,22 @@ func main() { func loadConfig() (service.Config, string) { cfg := service.Config{ - Host: "127.0.0.1", - Port: 8095, - Backend: service.BackendRemote, - Transport: lingmaipc.TransportAuto, - Cwd: currentDir(), - Mode: "agent", - Model: "kmodel", - ShellType: defaultShellType(), - SessionMode: service.SessionModeAuto, - Timeout: 0, - RemoteFallbackEnabled: true, - RemoteFallbackModels: service.DefaultRemoteFallbackModels(), + Host: "127.0.0.1", + Port: 8095, + Backend: service.BackendRemote, + Transport: lingmaipc.TransportAuto, + Cwd: currentDir(), + Mode: "agent", + Model: "kmodel", + ShellType: defaultShellType(), + SessionMode: service.SessionModeAuto, + Timeout: 0, + RemoteFallbackEnabled: true, + RemoteFallbackModels: service.DefaultRemoteFallbackModels(), + LingmaBootstrapEnabled: false, + LingmaSourceType: "marketplace", + LingmaBootstrapAlways: true, + LingmaForceRefresh: false, } configPath, configLoaded := resolveConfigPath() @@ -134,6 +153,18 @@ func loadConfig() (service.Config, string) { remoteFallbackEnabled := flag.Bool("remote-fallback", cfg.RemoteFallbackEnabled, "Enable remote timeout/5xx fallback to the next available model") remoteFallbackModels := flag.String("remote-fallback-models", strings.Join(cfg.RemoteFallbackModels, ","), "Comma-separated remote fallback model IDs") sessionMode := flag.String("session-mode", string(cfg.SessionMode), "Session mode: auto, fresh, reuse") + lingmaBootstrap := flag.Bool("lingma-bootstrap", cfg.LingmaBootstrapEnabled, "Download/extract Lingma runtime assets before startup") + lingmaSourceType := flag.String("lingma-source-type", cfg.LingmaSourceType, "Lingma bootstrap source: marketplace or vsix") + lingmaVSIXURL := flag.String("lingma-vsix-url", cfg.LingmaVSIXURL, "Lingma VSIX URL used when bootstrap source is vsix or marketplace fallback") + lingmaMarketplacePublisher := flag.String("lingma-marketplace-publisher", cfg.LingmaMarketplacePublisher, "VS Code marketplace publisher for Lingma bootstrap") + lingmaMarketplaceExtension := flag.String("lingma-marketplace-extension", cfg.LingmaMarketplaceExtension, "VS Code marketplace extension name for Lingma bootstrap") + lingmaBootstrapOutputDir := flag.String("lingma-bootstrap-output-dir", cfg.LingmaBootstrapOutputDir, "Lingma bootstrap release output directory") + lingmaBinaryPath := flag.String("lingma-binary-path", cfg.LingmaBinaryPath, "Lingma binary output path") + lingmaBootstrapAlways := flag.Bool("lingma-bootstrap-always", cfg.LingmaBootstrapAlways, "Re-check bootstrap source at startup") + lingmaForceRefresh := flag.Bool("lingma-force-refresh", cfg.LingmaForceRefresh, "Force refresh Lingma bootstrap assets") + lingmaWorkDir := flag.String("lingma-work-dir", cfg.LingmaWorkDir, "Lingma work/cache directory used for restored session bundles") + lingmaSessionBundle := flag.String("lingma-session-bundle", cfg.LingmaSessionBundle, "Base64 tar.gz Lingma session bundle to restore before startup") + lingmaSessionBundleFile := flag.String("lingma-session-bundle-file", cfg.LingmaSessionBundleFile, "File containing a base64 tar.gz Lingma session bundle") config := flag.String("config", valueOr(configPath, filepath.Join(currentDir(), "lingma-proxy.json")), "Path to JSON config file") flag.Parse() @@ -159,6 +190,18 @@ func loadConfig() (service.Config, string) { cfg.Timeout = time.Duration(*timeoutSeconds) * time.Second cfg.RemoteFallbackEnabled = *remoteFallbackEnabled cfg.RemoteFallbackModels = splitCSV(*remoteFallbackModels) + cfg.LingmaBootstrapEnabled = *lingmaBootstrap + cfg.LingmaSourceType = strings.TrimSpace(*lingmaSourceType) + cfg.LingmaVSIXURL = strings.TrimSpace(*lingmaVSIXURL) + cfg.LingmaMarketplacePublisher = strings.TrimSpace(*lingmaMarketplacePublisher) + cfg.LingmaMarketplaceExtension = strings.TrimSpace(*lingmaMarketplaceExtension) + cfg.LingmaBootstrapOutputDir = strings.TrimSpace(*lingmaBootstrapOutputDir) + cfg.LingmaBinaryPath = strings.TrimSpace(*lingmaBinaryPath) + cfg.LingmaBootstrapAlways = *lingmaBootstrapAlways + cfg.LingmaForceRefresh = *lingmaForceRefresh + cfg.LingmaWorkDir = strings.TrimSpace(*lingmaWorkDir) + cfg.LingmaSessionBundle = strings.TrimSpace(*lingmaSessionBundle) + cfg.LingmaSessionBundleFile = strings.TrimSpace(*lingmaSessionBundleFile) if configLoaded { configPath = finalConfigPath @@ -252,6 +295,42 @@ func overlayFileConfig(dst *service.Config, src fileConfig) { if len(src.RemoteFallbackModels) > 0 { dst.RemoteFallbackModels = cleanStringSlice(src.RemoteFallbackModels) } + if src.LingmaBootstrapEnabled != nil { + dst.LingmaBootstrapEnabled = *src.LingmaBootstrapEnabled + } + if strings.TrimSpace(src.LingmaSourceType) != "" { + dst.LingmaSourceType = strings.TrimSpace(src.LingmaSourceType) + } + if strings.TrimSpace(src.LingmaVSIXURL) != "" { + dst.LingmaVSIXURL = strings.TrimSpace(src.LingmaVSIXURL) + } + if strings.TrimSpace(src.LingmaMarketplacePublisher) != "" { + dst.LingmaMarketplacePublisher = strings.TrimSpace(src.LingmaMarketplacePublisher) + } + if strings.TrimSpace(src.LingmaMarketplaceExtension) != "" { + dst.LingmaMarketplaceExtension = strings.TrimSpace(src.LingmaMarketplaceExtension) + } + if strings.TrimSpace(src.LingmaBootstrapOutputDir) != "" { + dst.LingmaBootstrapOutputDir = strings.TrimSpace(src.LingmaBootstrapOutputDir) + } + if strings.TrimSpace(src.LingmaBinaryPath) != "" { + dst.LingmaBinaryPath = strings.TrimSpace(src.LingmaBinaryPath) + } + if src.LingmaBootstrapAlways != nil { + dst.LingmaBootstrapAlways = *src.LingmaBootstrapAlways + } + if src.LingmaForceRefresh != nil { + dst.LingmaForceRefresh = *src.LingmaForceRefresh + } + if strings.TrimSpace(src.LingmaWorkDir) != "" { + dst.LingmaWorkDir = strings.TrimSpace(src.LingmaWorkDir) + } + if strings.TrimSpace(src.LingmaSessionBundle) != "" { + dst.LingmaSessionBundle = strings.TrimSpace(src.LingmaSessionBundle) + } + if strings.TrimSpace(src.LingmaSessionBundleFile) != "" { + dst.LingmaSessionBundleFile = strings.TrimSpace(src.LingmaSessionBundleFile) + } } func overlayEnvConfig(dst *service.Config) { @@ -309,6 +388,42 @@ func overlayEnvConfig(dst *service.Config) { if value := strings.TrimSpace(os.Getenv("LINGMA_REMOTE_FALLBACK_MODELS")); value != "" { dst.RemoteFallbackModels = splitCSV(value) } + if value, ok := envBool("LINGMA_BOOTSTRAP_ENABLED"); ok { + dst.LingmaBootstrapEnabled = value + } + if value := strings.TrimSpace(os.Getenv("LINGMA_SOURCE_TYPE")); value != "" { + dst.LingmaSourceType = value + } + if value := strings.TrimSpace(os.Getenv("LINGMA_VSIX_URL")); value != "" { + dst.LingmaVSIXURL = value + } + if value := strings.TrimSpace(os.Getenv("LINGMA_MARKETPLACE_PUBLISHER")); value != "" { + dst.LingmaMarketplacePublisher = value + } + if value := strings.TrimSpace(os.Getenv("LINGMA_MARKETPLACE_EXTENSION")); value != "" { + dst.LingmaMarketplaceExtension = value + } + if value := strings.TrimSpace(os.Getenv("LINGMA_BOOTSTRAP_OUTPUT_DIR")); value != "" { + dst.LingmaBootstrapOutputDir = value + } + if value := strings.TrimSpace(os.Getenv("LINGMA_BIN")); value != "" { + dst.LingmaBinaryPath = value + } + if value, ok := envBool("LINGMA_BOOTSTRAP_ALWAYS"); ok { + dst.LingmaBootstrapAlways = value + } + if value, ok := envBool("LINGMA_FORCE_REFRESH"); ok { + dst.LingmaForceRefresh = value + } + if value := strings.TrimSpace(os.Getenv("LINGMA_WORK_DIR")); value != "" { + dst.LingmaWorkDir = value + } + if value := strings.TrimSpace(os.Getenv("LINGMA_SESSION_BUNDLE")); value != "" { + dst.LingmaSessionBundle = value + } + if value := strings.TrimSpace(os.Getenv("LINGMA_SESSION_BUNDLE_FILE")); value != "" { + dst.LingmaSessionBundleFile = value + } } func parseSessionMode(value string) service.SessionMode { diff --git a/config.example.json b/config.example.json index eae2963..903541f 100644 --- a/config.example.json +++ b/config.example.json @@ -20,5 +20,18 @@ "shell_type": "powershell", "current_file_path": "", "pipe": "", - "websocket_url": "" + "websocket_url": "", + "remote_auth_file": "/secrets/credentials.json", + "lingma_bootstrap_enabled": true, + "lingma_source_type": "marketplace", + "lingma_vsix_url": "", + "lingma_marketplace_publisher": "Alibaba-Cloud", + "lingma_marketplace_extension": "tongyi-lingma", + "lingma_binary_path": "/app/data/bin/Lingma", + "lingma_bootstrap_output_dir": "/app/data/bin/release", + "lingma_bootstrap_always": true, + "lingma_force_refresh": false, + "lingma_work_dir": "/app/data/.lingma/vscode/sharedClientCache", + "lingma_session_bundle": "", + "lingma_session_bundle_file": "/secrets/session.bundle" } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3c75c43 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +services: + lingma-proxy: + build: + context: . + container_name: lingma-proxy + env_file: + - .env + ports: + - "${LINGMA_PROXY_PORT:-8095}:8095" + volumes: + - ./data:/app/data + - ./secrets:/secrets:ro + restart: unless-stopped + healthcheck: + test: + [ + "CMD", + "sh", + "-c", + "wget -q -O - http://127.0.0.1:8095/runtime/status | grep -q '\"ok\":true'" + ] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s diff --git a/internal/bootstrap/lingma.go b/internal/bootstrap/lingma.go new file mode 100644 index 0000000..a2e8a33 --- /dev/null +++ b/internal/bootstrap/lingma.go @@ -0,0 +1,476 @@ +package bootstrap + +import ( + "archive/zip" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "time" +) + +type Config struct { + Enabled bool + SourceType string + VSIXURL string + MarketplacePublisher string + MarketplaceExtension string + OutputDir string + BinaryPath string + AlwaysRefresh bool + ForceRefresh bool + HTTPTimeout time.Duration +} + +type Result struct { + Enabled bool `json:"enabled"` + Source string `json:"source,omitempty"` + Version string `json:"version,omitempty"` + URL string `json:"url,omitempty"` + BinaryPath string `json:"binary_path,omitempty"` + ReleaseDir string `json:"release_dir,omitempty"` + MarkerPath string `json:"marker_path,omitempty"` + Downloaded bool `json:"downloaded"` + ReusedExisting bool `json:"reused_existing"` + Message string `json:"message,omitempty"` +} + +type marker struct { + Source string `json:"source"` + URL string `json:"url"` + Version string `json:"version"` + DownloadedAt int64 `json:"downloaded_at"` + NestedZip string `json:"nested_zip"` + Member string `json:"member"` + ReleaseRoot string `json:"release_root"` + Size int `json:"size"` + Publisher string `json:"publisher,omitempty"` + Extension string `json:"extension,omitempty"` +} + +func Ensure(cfg Config) (Result, error) { + result := Result{Enabled: cfg.Enabled} + if !cfg.Enabled { + result.Message = "bootstrap disabled" + return result, nil + } + cfg = normalize(cfg) + if cfg.BinaryPath == "" { + return result, fmt.Errorf("bootstrap binary path is required") + } + if err := os.MkdirAll(filepath.Dir(cfg.BinaryPath), 0o755); err != nil { + return result, err + } + if err := os.MkdirAll(cfg.OutputDir, 0o755); err != nil { + return result, err + } + + resolvedURL := strings.TrimSpace(cfg.VSIXURL) + version := "" + if cfg.SourceType == "marketplace" { + url, ver, err := queryMarketplaceLatestVSIX(cfg) + if err == nil { + resolvedURL = url + version = ver + } else if resolvedURL == "" { + return result, err + } + } + if resolvedURL == "" { + return result, fmt.Errorf("bootstrap VSIX URL is empty") + } + + result.Source = cfg.SourceType + result.URL = resolvedURL + result.Version = version + result.BinaryPath = cfg.BinaryPath + result.MarkerPath = filepath.Join(filepath.Dir(cfg.BinaryPath), ".lingma-bootstrap.json") + + oldMarker := readMarker(result.MarkerPath) + releaseDir := releaseDirForOutput(cfg.OutputDir, oldMarker.ReleaseRoot) + result.ReleaseDir = releaseDir + ready := hasRequiredAssets(releaseDir) + + if fileExists(cfg.BinaryPath) && ready && !cfg.ForceRefresh { + if !cfg.AlwaysRefresh || (version != "" && oldMarker.Version == version) { + if err := os.Chmod(cfg.BinaryPath, 0o755); err != nil { + return result, err + } + result.ReusedExisting = true + result.Message = "reused existing Lingma binary" + return result, nil + } + } + + vsixBytes, err := downloadVSIX(resolvedURL, cfg.HTTPTimeout) + if err != nil { + if fileExists(cfg.BinaryPath) { + result.ReusedExisting = true + result.Message = fmt.Sprintf("download failed, reused existing Lingma: %v", err) + return result, os.Chmod(cfg.BinaryPath, 0o755) + } + return result, err + } + result.Downloaded = true + + nestedName, nestedBytes, err := extractNestedZip(vsixBytes) + if err != nil { + return result, err + } + memberName, binaryBytes, releaseRoot, releaseFiles, err := extractRelease(nestedBytes) + if err != nil { + return result, err + } + releaseDir = releaseDirForOutput(cfg.OutputDir, releaseRoot) + result.ReleaseDir = releaseDir + if err := os.RemoveAll(releaseDir); err != nil { + return result, err + } + if err := writeReleaseFiles(releaseDir, releaseRoot, releaseFiles); err != nil { + return result, err + } + if err := os.WriteFile(cfg.BinaryPath, binaryBytes, 0o755); err != nil { + return result, err + } + if !hasRequiredAssets(releaseDir) { + return result, fmt.Errorf("extension assets missing after extraction under %s", releaseDir) + } + + mk := marker{ + Source: cfg.SourceType, + URL: resolvedURL, + Version: version, + DownloadedAt: time.Now().Unix(), + NestedZip: nestedName, + Member: memberName, + ReleaseRoot: releaseRoot, + Size: len(binaryBytes), + Publisher: cfg.MarketplacePublisher, + Extension: cfg.MarketplaceExtension, + } + if err := writeMarker(result.MarkerPath, mk); err != nil { + return result, err + } + result.Message = fmt.Sprintf("downloaded Lingma %s", versionOrUnknown(version)) + return result, nil +} + +type marketplaceResponse struct { + Results []struct { + Extensions []struct { + Versions []struct { + Version string `json:"version"` + Files []struct { + AssetType string `json:"assetType"` + Source string `json:"source"` + } `json:"files"` + } `json:"versions"` + } `json:"extensions"` + } `json:"results"` +} + +type zipEntry struct { + Name string + Data []byte + Mode os.FileMode +} + +func normalize(cfg Config) Config { + cfg.SourceType = strings.ToLower(strings.TrimSpace(cfg.SourceType)) + if cfg.SourceType == "" { + cfg.SourceType = "marketplace" + } + if cfg.OutputDir == "" { + cfg.OutputDir = filepath.Join(filepath.Dir(cfg.BinaryPath), "release") + } + if cfg.HTTPTimeout <= 0 { + cfg.HTTPTimeout = 30 * time.Second + } + if strings.TrimSpace(cfg.MarketplacePublisher) == "" { + cfg.MarketplacePublisher = "Alibaba-Cloud" + } + if strings.TrimSpace(cfg.MarketplaceExtension) == "" { + cfg.MarketplaceExtension = "tongyi-lingma" + } + if strings.TrimSpace(cfg.VSIXURL) == "" { + cfg.VSIXURL = "https://tongyi-code.oss-cn-hangzhou.aliyuncs.com/vscode/tongyi-lingma-latest.vsix" + } + return cfg +} + +func queryMarketplaceLatestVSIX(cfg Config) (string, string, error) { + payload := map[string]any{ + "filters": []any{map[string]any{ + "criteria": []any{ + map[string]any{"filterType": 7, "value": cfg.MarketplacePublisher + "." + cfg.MarketplaceExtension}, + map[string]any{"filterType": 8, "value": "Microsoft.VisualStudio.Code"}, + }, + "pageNumber": 1, + "pageSize": 1, + "sortBy": 0, + "sortOrder": 0, + }}, + "assetTypes": []any{}, + "flags": 950, + } + body, err := json.Marshal(payload) + if err != nil { + return "", "", err + } + req, err := http.NewRequest(http.MethodPost, "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery", bytes.NewReader(body)) + if err != nil { + return "", "", err + } + req.Header.Set("accept", "application/json;api-version=3.0-preview.1") + req.Header.Set("content-type", "application/json") + req.Header.Set("x-market-client-id", "VSCode 1.115.0") + client := &http.Client{Timeout: cfg.HTTPTimeout} + resp, err := client.Do(req) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return "", "", fmt.Errorf("marketplace query status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + var parsed marketplaceResponse + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + return "", "", err + } + if len(parsed.Results) == 0 || len(parsed.Results[0].Extensions) == 0 || len(parsed.Results[0].Extensions[0].Versions) == 0 { + return "", "", fmt.Errorf("no extension found from marketplace") + } + version := parsed.Results[0].Extensions[0].Versions[0].Version + for _, file := range parsed.Results[0].Extensions[0].Versions[0].Files { + if file.AssetType == "Microsoft.VisualStudio.Services.VSIXPackage" && strings.TrimSpace(file.Source) != "" { + return file.Source, version, nil + } + } + if version == "" { + return "", "", fmt.Errorf("no version/vsix url found from marketplace") + } + fallback := fmt.Sprintf("https://marketplace.visualstudio.com/_apis/public/gallery/publishers/%s/vsextensions/%s/%s/vspackage", cfg.MarketplacePublisher, cfg.MarketplaceExtension, version) + return fallback, version, nil +} + +func downloadVSIX(url string, timeout time.Duration) ([]byte, error) { + client := &http.Client{Timeout: timeout} + resp, err := client.Get(url) + if err != nil { + return nil, fmt.Errorf("download VSIX: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return nil, fmt.Errorf("download VSIX status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + return data, nil +} + +func extractNestedZip(vsix []byte) (string, []byte, error) { + reader, err := zip.NewReader(bytes.NewReader(vsix), int64(len(vsix))) + if err != nil { + return "", nil, err + } + candidates := make([]*zip.File, 0) + for _, file := range reader.File { + name := file.Name + if strings.HasPrefix(name, "extension/dist/bin/") && strings.HasSuffix(name, ".zip") && strings.Contains(name, "lingma-") { + candidates = append(candidates, file) + } + } + if len(candidates) == 0 { + return "", nil, fmt.Errorf("no lingma-*.zip found in VSIX") + } + sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name < candidates[j].Name }) + picked := candidates[len(candidates)-1] + rc, err := picked.Open() + if err != nil { + return "", nil, err + } + defer rc.Close() + data, err := io.ReadAll(rc) + if err != nil { + return "", nil, err + } + return picked.Name, data, nil +} + +func extractRelease(inner []byte) (string, []byte, string, []zipEntry, error) { + reader, err := zip.NewReader(bytes.NewReader(inner), int64(len(inner))) + if err != nil { + return "", nil, "", nil, err + } + binaryPath := pickLingmaBinaryPath(reader.File) + if binaryPath == "" { + return "", nil, "", nil, fmt.Errorf("Lingma binary not found inside nested zip") + } + releaseRoot := inferReleaseRoot(binaryPath) + entries := make([]zipEntry, 0, len(reader.File)) + var binaryBytes []byte + for _, file := range reader.File { + if file.FileInfo().IsDir() { + continue + } + if releaseRoot != "" && !strings.HasPrefix(file.Name, releaseRoot+"/") { + continue + } + rc, err := file.Open() + if err != nil { + return "", nil, "", nil, err + } + data, err := io.ReadAll(rc) + rc.Close() + if err != nil { + return "", nil, "", nil, err + } + entries = append(entries, zipEntry{Name: file.Name, Data: data, Mode: file.Mode()}) + if file.Name == binaryPath { + binaryBytes = data + } + } + if len(binaryBytes) == 0 { + return "", nil, "", nil, fmt.Errorf("Lingma binary bytes missing from nested zip") + } + return binaryPath, binaryBytes, releaseRoot, entries, nil +} + +func pickLingmaBinaryPath(files []*zip.File) string { + preferredSuffix := platformBinarySuffix() + for _, file := range files { + if strings.HasSuffix(file.Name, preferredSuffix) { + return file.Name + } + } + for _, file := range files { + if strings.HasSuffix(file.Name, "/Lingma") || file.Name == "Lingma" { + return file.Name + } + } + return "" +} + +func platformBinarySuffix() string { + if runtime.GOOS == "linux" && runtime.GOARCH == "amd64" { + return "x86_64_linux/Lingma" + } + if runtime.GOOS == "linux" && runtime.GOARCH == "arm64" { + return "arm64_linux/Lingma" + } + if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" { + return "arm64_darwin/Lingma" + } + if runtime.GOOS == "darwin" { + return "x86_64_darwin/Lingma" + } + if runtime.GOOS == "windows" && runtime.GOARCH == "arm64" { + return "arm64_windows/Lingma" + } + if runtime.GOOS == "windows" { + return "x86_64_windows/Lingma" + } + return "/Lingma" +} + +func inferReleaseRoot(memberPath string) string { + parts := strings.Split(memberPath, "/") + platforms := []string{"x86_64_linux", "arm64_linux", "x86_64_darwin", "arm64_darwin", "x86_64_windows", "arm64_windows"} + for _, platform := range platforms { + for i, part := range parts { + if part == platform && i > 0 { + return strings.Join(parts[:i], "/") + } + } + } + if len(parts) > 1 { + return parts[0] + } + return "" +} + +func writeReleaseFiles(releaseDir string, releaseRoot string, files []zipEntry) error { + prefix := "" + if releaseRoot != "" { + prefix = releaseRoot + "/" + } + for _, entry := range files { + rel := entry.Name + if prefix != "" { + rel = strings.TrimPrefix(rel, prefix) + } + if rel == "" { + continue + } + dest := filepath.Join(releaseDir, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return err + } + mode := entry.Mode + if mode == 0 { + mode = 0o644 + } + if strings.HasSuffix(rel, "/Lingma") || filepath.Base(rel) == "Lingma" { + mode = 0o755 + } + if err := os.WriteFile(dest, entry.Data, mode); err != nil { + return err + } + } + return nil +} + +func releaseDirForOutput(outputDir string, releaseRoot string) string { + name := strings.TrimSpace(releaseRoot) + if name == "" { + return outputDir + } + return filepath.Join(outputDir, filepath.FromSlash(name)) +} + +func hasRequiredAssets(releaseDir string) bool { + info, err := os.Stat(filepath.Join(releaseDir, "extension", "main.js")) + return err == nil && !info.IsDir() +} + +func readMarker(path string) marker { + body, err := os.ReadFile(path) + if err != nil { + return marker{} + } + var mk marker + if err := json.Unmarshal(body, &mk); err != nil { + return marker{} + } + return mk +} + +func writeMarker(path string, mk marker) error { + body, err := json.MarshalIndent(mk, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, body, 0o644) +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +func versionOrUnknown(version string) string { + if strings.TrimSpace(version) == "" { + return "unknown version" + } + return version +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 1718cfd..794d557 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -116,6 +116,8 @@ func NewServer(addr string, svc *service.Service) *Server { mux.HandleFunc("/api/tags", s.handleOllamaTags) mux.HandleFunc("/v1/props", s.handleModelProps) mux.HandleFunc("/props", s.handleModelProps) + mux.HandleFunc("/v1/runtime/status", s.handleRuntimeStatus) + mux.HandleFunc("/runtime/status", s.handleRuntimeStatus) mux.HandleFunc("/version", s.handleVersion) mux.HandleFunc("/v1/messages/count_tokens", s.handleAnthropicCountTokens) mux.HandleFunc("/v1/messages", s.handleAnthropicMessages) @@ -184,6 +186,27 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) handleRuntimeStatus(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + if r.Method == http.MethodHead { + w.WriteHeader(http.StatusOK) + return + } + if r.Method != http.MethodGet { + writeOpenAIError(w, http.StatusMethodNotAllowed, "invalid_request_error", "method not allowed") + return + } + state := s.svc.State() + writeJSON(w, http.StatusOK, map[string]any{ + "ok": state.Connected, + "service": "lingma-proxy", + "state": state, + }) +} + func (s *Server) handleDebugRequests(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) diff --git a/internal/remote/credentials.go b/internal/remote/credentials.go index e62de7d..7881edc 100644 --- a/internal/remote/credentials.go +++ b/internal/remote/credentials.go @@ -26,6 +26,15 @@ type Credential struct { TokenExpireTime int64 } +type CredentialStatus struct { + Loaded bool `json:"loaded"` + Source string `json:"source,omitempty"` + UserIDMasked string `json:"user_id_masked,omitempty"` + MachineMasked string `json:"machine_id_masked,omitempty"` + ExpireAt string `json:"expire_at,omitempty"` + Expired bool `json:"expired"` +} + type storedCredentialFile struct { Source string `json:"source"` TokenExpireTime string `json:"token_expire_time"` @@ -44,6 +53,24 @@ func LoadCredential(authFile string) (Credential, error) { return importLingmaCacheCredential() } +func LoadCredentialStatus(authFile string) (CredentialStatus, error) { + cred, err := LoadCredential(authFile) + if err != nil { + return CredentialStatus{}, err + } + status := CredentialStatus{ + Loaded: true, + Source: cred.Source, + UserIDMasked: maskTail(cred.UserID), + MachineMasked: maskTail(cred.MachineID), + Expired: IsExpired(cred, 0), + } + if cred.TokenExpireTime > 0 { + status.ExpireAt = time.UnixMilli(cred.TokenExpireTime).UTC().Format(time.RFC3339) + } + return status, nil +} + func loadCredentialFile(path string) (Credential, error) { body, err := os.ReadFile(path) if err != nil { @@ -359,6 +386,17 @@ func MachineOSHeader() string { } } +func maskTail(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + if len(value) <= 6 { + return strings.Repeat("*", len(value)) + } + return value[:3] + strings.Repeat("*", len(value)-6) + value[len(value)-3:] +} + func uniquePathStrings(values []string) []string { seen := make(map[string]struct{}, len(values)) out := make([]string, 0, len(values)) diff --git a/internal/service/service.go b/internal/service/service.go index 0f76751..6ed0b1a 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -14,8 +14,10 @@ import ( "sync" "time" + "lingma-ipc-proxy/internal/bootstrap" "lingma-ipc-proxy/internal/lingmaipc" "lingma-ipc-proxy/internal/remote" + "lingma-ipc-proxy/internal/sessionbundle" "lingma-ipc-proxy/internal/toolemulation" ) @@ -35,24 +37,36 @@ const ( ) type Config struct { - Host string - Port int - Backend BackendMode - Transport lingmaipc.Transport - Pipe string - WebSocketURL string - RemoteBaseURL string - RemoteAuthFile string - RemoteVersion string - Cwd string - CurrentFilePath string - Mode string - Model string - ShellType string - SessionMode SessionMode - Timeout time.Duration - RemoteFallbackEnabled bool - RemoteFallbackModels []string + Host string + Port int + Backend BackendMode + Transport lingmaipc.Transport + Pipe string + WebSocketURL string + RemoteBaseURL string + RemoteAuthFile string + RemoteVersion string + Cwd string + CurrentFilePath string + Mode string + Model string + ShellType string + SessionMode SessionMode + Timeout time.Duration + RemoteFallbackEnabled bool + RemoteFallbackModels []string + LingmaBootstrapEnabled bool + LingmaSourceType string + LingmaVSIXURL string + LingmaMarketplacePublisher string + LingmaMarketplaceExtension string + LingmaBootstrapOutputDir string + LingmaBinaryPath string + LingmaBootstrapAlways bool + LingmaForceRefresh bool + LingmaWorkDir string + LingmaSessionBundle string + LingmaSessionBundleFile string } type Image struct { @@ -127,12 +141,15 @@ type Model struct { } type State struct { - PipePath string `json:"pipe_path,omitempty"` - Endpoint string `json:"endpoint,omitempty"` - Transport string `json:"transport,omitempty"` - Connected bool `json:"connected"` - StickySessionID string `json:"sticky_session_id,omitempty"` - SessionMode SessionMode `json:"session_mode"` + PipePath string `json:"pipe_path,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + Transport string `json:"transport,omitempty"` + Connected bool `json:"connected"` + StickySessionID string `json:"sticky_session_id,omitempty"` + SessionMode SessionMode `json:"session_mode"` + Bootstrap bootstrap.Result `json:"bootstrap,omitempty"` + SessionBundle sessionbundle.Result `json:"session_bundle,omitempty"` + RemoteAuth *remote.CredentialStatus `json:"remote_auth,omitempty"` } type Service struct { @@ -146,6 +163,8 @@ type Service struct { stickyModelID string modelMap map[string]string // official name -> internal id remoteClient *remote.Client + bootstrapState bootstrap.Result + sessionState sessionbundle.Result } type promptRunResult struct { @@ -184,6 +203,24 @@ func New(cfg Config) *Service { if cfg.SessionMode == "" { cfg.SessionMode = SessionModeAuto } + if strings.TrimSpace(cfg.LingmaSourceType) == "" { + cfg.LingmaSourceType = "marketplace" + } + if strings.TrimSpace(cfg.LingmaMarketplacePublisher) == "" { + cfg.LingmaMarketplacePublisher = "Alibaba-Cloud" + } + if strings.TrimSpace(cfg.LingmaMarketplaceExtension) == "" { + cfg.LingmaMarketplaceExtension = "tongyi-lingma" + } + if strings.TrimSpace(cfg.LingmaBinaryPath) == "" { + cfg.LingmaBinaryPath = filepath.Join(os.TempDir(), "lingma-proxy", "bin", "Lingma") + } + if strings.TrimSpace(cfg.LingmaBootstrapOutputDir) == "" { + cfg.LingmaBootstrapOutputDir = filepath.Join(filepath.Dir(cfg.LingmaBinaryPath), "release") + } + if strings.TrimSpace(cfg.LingmaWorkDir) == "" { + cfg.LingmaWorkDir = filepath.Join(filepath.Dir(filepath.Dir(cfg.LingmaBinaryPath)), ".lingma", "vscode", "sharedClientCache") + } return &Service{cfg: cfg} } @@ -210,6 +247,44 @@ func (s *Service) DefaultModel() string { return strings.TrimSpace(s.cfg.Model) } +func (s *Service) PrepareRuntime() error { + s.mu.Lock() + cfg := s.cfg + s.mu.Unlock() + + sessionState, err := sessionbundle.Restore(cfg.LingmaWorkDir, cfg.LingmaSessionBundle, cfg.LingmaSessionBundleFile) + if err != nil { + return err + } + if sessionState.Restored { + if err := os.Setenv("LINGMA_CACHE_DIR", cfg.LingmaWorkDir); err != nil { + return err + } + } + + bootstrapState, err := bootstrap.Ensure(bootstrap.Config{ + Enabled: cfg.LingmaBootstrapEnabled, + SourceType: cfg.LingmaSourceType, + VSIXURL: cfg.LingmaVSIXURL, + MarketplacePublisher: cfg.LingmaMarketplacePublisher, + MarketplaceExtension: cfg.LingmaMarketplaceExtension, + OutputDir: cfg.LingmaBootstrapOutputDir, + BinaryPath: cfg.LingmaBinaryPath, + AlwaysRefresh: cfg.LingmaBootstrapAlways, + ForceRefresh: cfg.LingmaForceRefresh, + HTTPTimeout: 30 * time.Second, + }) + if err != nil { + return err + } + + s.mu.Lock() + s.sessionState = sessionState + s.bootstrapState = bootstrapState + s.mu.Unlock() + return nil +} + func (s *Service) Warmup(ctx context.Context) error { if s.backend() == BackendRemote { return s.remoteClientLocked().Warmup(ctx) @@ -234,22 +309,26 @@ func contextWithOptionalTimeout(parent context.Context, timeout time.Duration) ( func (s *Service) State() State { s.mu.Lock() defer s.mu.Unlock() + state := State{ + SessionMode: s.cfg.SessionMode, + Bootstrap: s.bootstrapState, + SessionBundle: s.sessionState, + } if s.cfg.Backend == BackendRemote { - return State{ - Endpoint: remote.ResolveBaseURL(s.cfg.RemoteBaseURL), - Transport: "remote", - Connected: s.remoteClient != nil, - SessionMode: s.cfg.SessionMode, + state.Endpoint = remote.ResolveBaseURL(s.cfg.RemoteBaseURL) + state.Transport = "remote" + if status, err := remote.LoadCredentialStatus(s.cfg.RemoteAuthFile); err == nil { + state.RemoteAuth = &status + state.Connected = status.Loaded && !status.Expired } + return state } - return State{ - PipePath: s.pipePath, - Endpoint: s.endpoint, - Transport: string(s.transport), - Connected: s.client != nil, - StickySessionID: s.stickySessionID, - SessionMode: s.cfg.SessionMode, - } + state.PipePath = s.pipePath + state.Endpoint = s.endpoint + state.Transport = string(s.transport) + state.Connected = s.client != nil + state.StickySessionID = s.stickySessionID + return state } func (s *Service) ListModels(ctx context.Context) ([]Model, error) { diff --git a/internal/sessionbundle/bundle.go b/internal/sessionbundle/bundle.go new file mode 100644 index 0000000..49c3a7b --- /dev/null +++ b/internal/sessionbundle/bundle.go @@ -0,0 +1,193 @@ +package sessionbundle + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "encoding/base64" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +const maxBundleBytes = 4 * 1024 * 1024 + +var bundleFiles = map[string]struct{}{ + "cache/id": {}, + "cache/user": {}, + "cache/quota": {}, + "cache/config.json": {}, +} + +type Result struct { + Configured bool `json:"configured"` + Restored bool `json:"restored"` + Source string `json:"source,omitempty"` + WorkDir string `json:"work_dir,omitempty"` + Files []string `json:"files,omitempty"` + Message string `json:"message,omitempty"` +} + +func Restore(workDir string, inline string, filePath string) (Result, error) { + result := Result{ + Configured: strings.TrimSpace(inline) != "" || strings.TrimSpace(filePath) != "", + WorkDir: strings.TrimSpace(workDir), + } + if !result.Configured { + result.Message = "session bundle not configured" + return result, nil + } + if result.WorkDir == "" { + return result, fmt.Errorf("session bundle restore requires a work dir") + } + + bundle, source, err := resolveBundle(inline, filePath) + if err != nil { + return result, err + } + raw, err := DecodeBundle(bundle) + if err != nil { + return result, err + } + files, err := ApplyBundleToWorkDir(result.WorkDir, raw) + if err != nil { + return result, err + } + result.Restored = true + result.Source = source + result.Files = files + result.Message = fmt.Sprintf("restored %d files", len(files)) + return result, nil +} + +func DecodeBundle(b64 string) ([]byte, error) { + b64 = strings.TrimSpace(b64) + if b64 == "" { + return nil, fmt.Errorf("empty bundle") + } + raw, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return nil, fmt.Errorf("invalid base64: %w", err) + } + if len(raw) > maxBundleBytes { + return nil, fmt.Errorf("bundle too large: %d bytes", len(raw)) + } + return raw, nil +} + +func ApplyBundleToWorkDir(workDir string, raw []byte) ([]string, error) { + if len(raw) > maxBundleBytes { + return nil, fmt.Errorf("bundle too large: %d bytes", len(raw)) + } + if err := os.MkdirAll(workDir, 0o755); err != nil { + return nil, err + } + + gz, err := gzip.NewReader(bytes.NewReader(raw)) + if err != nil { + return nil, fmt.Errorf("open bundle gzip: %w", err) + } + defer gz.Close() + + reader := tar.NewReader(gz) + restored := make([]string, 0, len(bundleFiles)) + var total int64 + for { + header, err := reader.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("read bundle tar: %w", err) + } + if !isSafeMember(header) { + continue + } + total += header.Size + if total > maxBundleBytes { + return nil, fmt.Errorf("bundle expanded too large: %d bytes", total) + } + + dest := filepath.Join(workDir, filepath.FromSlash(header.Name)) + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return nil, err + } + file, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, fileModeFor(header.Name)) + if err != nil { + return nil, err + } + if _, err := io.Copy(file, reader); err != nil { + file.Close() + return nil, err + } + if err := file.Close(); err != nil { + return nil, err + } + restored = append(restored, header.Name) + } + return restored, nil +} + +func resolveBundle(inline string, filePath string) (string, string, error) { + if value := strings.TrimSpace(inline); value != "" { + return value, "inline", nil + } + path := strings.TrimSpace(filePath) + if path == "" { + return "", "", fmt.Errorf("session bundle file path is empty") + } + expanded := expandHome(path) + body, err := os.ReadFile(expanded) + if err != nil { + return "", "", fmt.Errorf("read bundle file: %w", err) + } + return strings.TrimSpace(string(body)), filepath.Base(expanded), nil +} + +func isSafeMember(header *tar.Header) bool { + if header == nil { + return false + } + if _, ok := bundleFiles[header.Name]; !ok { + return false + } + if header.Typeflag != tar.TypeReg && header.Typeflag != tar.TypeRegA { + return false + } + if header.Name == "" || strings.HasPrefix(header.Name, "/") { + return false + } + for _, part := range strings.Split(header.Name, "/") { + if part == ".." { + return false + } + } + return true +} + +func fileModeFor(name string) os.FileMode { + if strings.HasSuffix(name, "/user") { + return 0o600 + } + return 0o644 +} + +func expandHome(path string) string { + path = strings.TrimSpace(path) + if path == "" || path[0] != '~' { + return path + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return path + } + if path == "~" { + return home + } + if len(path) > 1 && (path[1] == '/' || path[1] == '\\') { + return filepath.Join(home, path[2:]) + } + return path +}