Add Docker Compose Lingma bootstrap support

Package the proxy for Docker Compose deployments and add Lingma bootstrap, session restore, and runtime status support for containerized remote usage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
GitHub Actions
2026-05-07 23:56:05 +08:00
parent 86fbdbc40c
commit a4cedecca6
10 changed files with 1067 additions and 68 deletions

23
.env.example Normal file
View File

@@ -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

14
Dockerfile Normal file
View File

@@ -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"]

View File

@@ -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 {

View File

@@ -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"
}

25
docker-compose.yml Normal file
View File

@@ -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

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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))

View File

@@ -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) {

View File

@@ -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
}