Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
8e64543
docs: design linux headless server
Jun 13, 2026
862cc7a
feat(headless): add Linux device auth configs
Jun 13, 2026
9f03f1d
feat(headless): print device auth QR codes
Jun 13, 2026
9bb8cde
fix(headless): harden terminal QR output
Jun 13, 2026
6fa9765
feat(headless): share model proxy daemon
Jun 13, 2026
0d5ca5c
fix(headless): harden model proxy daemon lifecycle
Jun 13, 2026
0208328
fix(headless): treat daemon context deadlines as shutdown
Jun 13, 2026
e3fcbd8
fix(headless): detach proxy daemon from caller context
Jun 13, 2026
7f9ee1b
feat(headless): enforce modelserver access rule
Jun 13, 2026
72cb17a
test(headless): cover model access edge cases
Jun 13, 2026
2fed96b
fix(headless): allow direct model access without secrets
Jun 13, 2026
3616684
fix(headless): avoid proxy config on access setup failure
Jun 13, 2026
366bd1d
feat(headless): resolve Linux runtime assets
Jun 13, 2026
03bac61
fix(headless): validate managed codex runtime paths
Jun 13, 2026
d7676eb
fix(headless): reject relative managed codex paths
Jun 13, 2026
ee261ff
fix(headless): validate codex managed runtime layout
Jun 13, 2026
bc47efe
test(headless): gate linux codex runtime tests
Jun 13, 2026
4be3857
feat(headless): reuse slaves by canonical folder
Jun 13, 2026
267fbb1
fix(headless): harden canonical slave registry lookup
Jun 13, 2026
a7e2bf7
feat(headless): run directory slaves in foreground
Jun 13, 2026
a0f9c94
fix(headless): harden foreground slave runner
Jun 13, 2026
6a2b620
fix(headless): validate foreground auth URLs
Jun 13, 2026
3ebfc5b
fix(headless): tighten foreground auth detection
Jun 13, 2026
fcba0eb
fix(headless): require exact auth path markers
Jun 13, 2026
6fa97cb
feat(headless): install Linux driver MCP
Jun 13, 2026
6be26ab
fix(headless): repair existing driver state
Jun 13, 2026
18274b7
fix(headless): repair driver workspace metadata
Jun 13, 2026
afe3230
fix(headless): harden driver install paths
Jun 13, 2026
184cab8
fix(headless): refresh driver workspace state
Jun 13, 2026
f29e378
fix(headless): refresh driver repair metadata
Jun 13, 2026
c1e652f
feat(headless): add agentserver CLI
Jun 13, 2026
8061ada
fix(headless): harden agentserver CLI routing
Jun 13, 2026
b09bb4f
build(headless): package Linux agentserver
Jun 13, 2026
507069f
fix(headless): package prebuilt Linux binaries
Jun 13, 2026
0597432
fix(headless): align Linux packaging outputs
Jun 13, 2026
88f1021
test(headless): assert Linux packaging preflight order
Jun 13, 2026
964bb86
fix(headless): harden MCP access and token refresh
Jun 13, 2026
a39febd
fix(headless): satisfy Linux auth and daemon review
Jun 13, 2026
7d2dfab
fix(headless): secure local proxy access
Jun 13, 2026
c2b04b3
fix(headless): address linux proxy review
Jun 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: all build test test-unit test-integration lint clean cross-windows package help ui-build ui-test
.PHONY: all build test test-unit test-integration lint clean cross-windows cross-linux package package-linux help ui-build ui-test

GO ?= go
GOFLAGS ?= -trimpath
Expand All @@ -14,6 +14,7 @@ all: build
help:
@echo "make build - build native binaries to dist/<os>/"
@echo "make cross-windows - cross-compile windows/amd64 to dist/windows/ (depends on ui-build)"
@echo "make cross-linux - cross-compile linux amd64/arm64 agentserver"
@echo "make test - go test -race ./..."
@echo "make test-unit - unit tests only (-short)"
@echo "make test-integration - integration tests (test/integration)"
Expand All @@ -22,6 +23,7 @@ help:
@echo "make ui-build - build onboarding Vue front-end into internal/ui/assets/dist/"
@echo "make ui-test - run frontend unit tests"
@echo "make package - build Windows .exe installer (requires Inno Setup; depends on ui-build + ext-build)"
@echo "make package-linux - build Linux headless tarballs"
@echo "make clean - rm dist/ and out/"

build: ui-build
Expand All @@ -43,6 +45,15 @@ cross-windows: ui-build
-o $(DIST)/windows/$$cmd.exe ./cmd/$$cmd ; \
done

cross-linux:
@mkdir -p $(DIST)/linux/amd64 $(DIST)/linux/arm64
@for arch in amd64 arm64; do \
echo "==> cross-building agentserver (linux/$$arch)"; \
CGO_ENABLED=0 GOOS=linux GOARCH=$$arch \
$(GO) build $(GOFLAGS) -ldflags="$(LDFLAGS)" \
-o $(DIST)/linux/$$arch/agentserver ./cmd/agentserver ; \
done

test: ui-build
$(GO) test -race -count=1 ./...

Expand All @@ -69,5 +80,8 @@ ui-test:
package: cross-windows ext-build
bash scripts/package-windows.sh

package-linux: cross-linux
OUT="$(DIST)" bash scripts/package-linux.sh

clean:
rm -rf $(DIST) out coverage.out internal/ui/assets/dist
8 changes: 4 additions & 4 deletions cmd/agentctl/cmd_test_subcommands.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ func runTestConfigure() {
die(err)
}
fmt.Printf("wrote codex config: %s\n", p.CodexConfigFile)
fmt.Printf("setx %s=%s (HKCU\\Environment)\n", codex.LocalProxyAPIKeyEnv, codex.LocalProxyAPIKeyValue)
fmt.Printf("setx %s=%s (HKCU\\Environment)\n", codex.LocalProxyAPIKeyEnv, codex.LegacyLocalProxyAPIKeyValue)

// .vsix sits next to the running agentctl.exe
exeDir, _ := os.Executable()
Expand Down Expand Up @@ -254,13 +254,13 @@ func openTestFolder(ctx context.Context, s *state.State, p paths.Paths, folder s
}

func configureTestCodex(p paths.Paths) error {
if err := codex.UpdateConfig(p.CodexConfigFile, codex.ModelserverProxySettings(modelproxy.DefaultBaseURL)); err != nil {
if err := codex.UpdateConfig(p.CodexConfigFile, codex.ModelserverProxySettings(modelproxy.DefaultBaseURL, codex.LegacyLocalProxyAPIKeyValue)); err != nil {
return err
}
if err := env.PersistUserEnv(codex.LocalProxyAPIKeyEnv, codex.LocalProxyAPIKeyValue); err != nil {
if err := env.PersistUserEnv(codex.LocalProxyAPIKeyEnv, codex.LegacyLocalProxyAPIKeyValue); err != nil {
return err
}
return os.Setenv(codex.LocalProxyAPIKeyEnv, codex.LocalProxyAPIKeyValue)
return os.Setenv(codex.LocalProxyAPIKeyEnv, codex.LegacyLocalProxyAPIKeyValue)
}

func startTestVSCode(codeExe string, args []string) (int, error) {
Expand Down
4 changes: 2 additions & 2 deletions cmd/agentctl/cmd_test_subcommands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ func TestOpenTestFolderCodexDesktopUsesDeepLinkAndWritesConfig(t *testing.T) {
if !strings.Contains(string(b), `base_url = "`+modelproxy.DefaultBaseURL+`"`) {
t.Fatalf("config missing local proxy base_url:\n%s", b)
}
if !strings.Contains(string(b), `env_key = "`+codex.LocalProxyAPIKeyEnv+`"`) {
t.Fatalf("config missing local proxy env_key:\n%s", b)
if !strings.Contains(string(b), `experimental_bearer_token = "`+codex.LegacyLocalProxyAPIKeyValue+`"`) {
t.Fatalf("config missing local proxy bearer token:\n%s", b)
}
}

Expand Down
261 changes: 261 additions & 0 deletions cmd/agentserver/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
package main

import (
"bufio"
"context"
"errors"
"fmt"
"io"
"log"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"

"github.com/agentserver/agentserver-pkg/internal/headless"
"github.com/agentserver/agentserver-pkg/internal/modelaccess"
"github.com/agentserver/agentserver-pkg/internal/modelserver"
"github.com/agentserver/agentserver-pkg/internal/oauth"
"github.com/agentserver/agentserver-pkg/internal/paths"
"github.com/agentserver/agentserver-pkg/internal/secrets"
"github.com/agentserver/agentserver-pkg/internal/terminalauth"
"golang.org/x/term"
)

type app struct {
ensureAccess func(context.Context) error
ensureMCPAccess func(context.Context) error
ensureCodex func(context.Context) error
runSlave func(context.Context) error
installDriver func(context.Context) error
switchWorkspace func(context.Context) error
serveDriverMCP func(context.Context) error
runDaemon func(context.Context) error
}

func main() {
ctx, stop := commandContext(context.Background())
defer stop()
if err := newApp().run(ctx, os.Args[1:]); err != nil {
log.Fatalf("agentserver: %v", err)
}
}

func commandContext(parent context.Context) (context.Context, context.CancelFunc) {
return signal.NotifyContext(parent, os.Interrupt, syscall.SIGTERM)
}

func newApp() app {
p, err := paths.Default()
if err != nil {
return app{
ensureAccess: func(context.Context) error { return err },
runDaemon: func(context.Context) error { return err },
}
}
exe, err := os.Executable()
if err != nil {
return app{
ensureAccess: func(context.Context) error { return err },
runDaemon: func(context.Context) error { return err },
}
}
pkg := headless.PackagePaths(exe)
sec := secrets.New(p.SecretsFile)
proxyTokenPath := modelaccess.DefaultLocalProxyTokenPath(p.InstallRoot)
proxyLogPath := filepath.Join(p.InstallRoot, "logs", "model-proxy-daemon.log")
cachedCodex := ""
ensureModelAccess := func(out io.Writer) func(context.Context) error {
return func(ctx context.Context) error {
proxyToken, err := modelaccess.EnsureLocalProxyToken(proxyTokenPath)
if err != nil {
return err
}
if _, err := modelaccess.Ensure(ctx, modelaccess.EnsureOptions{
CodexConfigPath: p.CodexConfigFile,
Secrets: sec,
LocalProxyToken: proxyToken,
PrintChallenge: func(title string, ch oauth.DeviceCodeChallenge) {
terminalauth.PrintChallenge(out, title, ch, terminalauth.DefaultQR)
},
StartDaemon: func(ctx context.Context) error {
return modelaccess.EnsureDaemon(ctx, modelaccess.EnsureDaemonOptions{
ExePath: pkg.AgentserverExe,
ProxyBaseURL: "http://127.0.0.1:53452",
LogPath: proxyLogPath,
})
},
}); err != nil {
return err
}
return nil
}
}

return app{
ensureAccess: ensureModelAccess(os.Stdout),
ensureMCPAccess: ensureModelAccess(os.Stderr),
ensureCodex: func(ctx context.Context) error {
codexRuntime, err := headless.ResolveCodex(ctx, headless.CodexResolveOptions{
Paths: p,
Package: pkg,
})
if err != nil {
return err
}
cachedCodex = codexRuntime.Path
return nil
},
runSlave: func(ctx context.Context) error {
wd, err := os.Getwd()
if err != nil {
return fmt.Errorf("get current workdir: %w", err)
}
return headless.RunSlave(ctx, headless.SlaveOptions{
Paths: p,
Package: pkg,
WorkDir: wd,
NamePrompt: promptName,
Stdout: os.Stdout,
QR: terminalauth.DefaultQR,
CodexBin: cachedCodex,
})
},
installDriver: func(ctx context.Context) error {
return headless.InstallDriver(ctx, headless.DriverOptions{
Paths: p,
Package: pkg,
Secrets: sec,
Stdout: os.Stdout,
QR: terminalauth.DefaultQR,
})
},
switchWorkspace: func(ctx context.Context) error {
return headless.SwitchWorkspace(ctx, headless.DriverOptions{
Paths: p,
Package: pkg,
Secrets: sec,
Stdout: os.Stdout,
QR: terminalauth.DefaultQR,
})
},
serveDriverMCP: func(ctx context.Context) error {
wd, err := os.Getwd()
if err != nil {
return fmt.Errorf("get current workdir: %w", err)
}
return headless.ServeDriverMCP(ctx, headless.DriverMCPOptions{
Paths: p,
Package: pkg,
Secrets: sec,
WorkDir: wd,
})
},
runDaemon: func(ctx context.Context) error {
proxyToken, err := modelaccess.EnsureLocalProxyToken(proxyTokenPath)
if err != nil {
return err
}
return modelaccess.RunDaemon(ctx, modelaccess.DaemonOptions{
Secrets: sec,
OAuth: modelserver.OAuthConfig(),
LocalProxyToken: proxyToken,
LockPath: filepath.Join(p.InstallRoot, "token-refresher.lock"),
Logf: daemonLogf(proxyLogPath),
})
},
}
}

func daemonLogf(path string) func(string, ...any) {
return func(format string, args ...any) {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return
}
defer f.Close()
logger := log.New(f, "", log.LstdFlags)
logger.Printf(format, args...)
}
}

func (a app) run(ctx context.Context, args []string) error {
cmd := ""
if len(args) > 0 {
cmd = args[0]
}
if cmd == "model-proxy-daemon" {
return a.runDaemon(ctx)
}
switch cmd {
case "", "install-driver", "switch-workspace", "serve-driver-mcp":
default:
return fmt.Errorf("unknown command %q", cmd)
}

ensureAccess := a.ensureAccess
if cmd == "serve-driver-mcp" && a.ensureMCPAccess != nil {
ensureAccess = a.ensureMCPAccess
}
if ensureAccess != nil {
if err := ensureAccess(ctx); err != nil {
return err
}
}
if cmd == "" && a.ensureCodex != nil {
if err := a.ensureCodex(ctx); err != nil {
return err
}
}
switch cmd {
case "":
return a.runSlave(ctx)
case "install-driver":
return a.installDriver(ctx)
case "switch-workspace":
return a.switchWorkspace(ctx)
case "serve-driver-mcp":
return a.serveDriverMCP(ctx)
}
return nil
}

func promptName(defaultName string) (string, error) {
return promptNameWithTerminal(os.Stdin, os.Stdout, defaultName, func() bool {
return term.IsTerminal(int(os.Stdin.Fd()))
})
}

func promptNameWithTerminal(r io.Reader, w io.Writer, defaultName string, isTerminal func() bool) (string, error) {
if isTerminal == nil || !isTerminal() {
return defaultName, nil
}
return promptNameWithIO(r, w, defaultName)
}

func promptNameWithIO(r io.Reader, w io.Writer, defaultName string) (string, error) {
if w != nil {
fmt.Fprintf(w, "Slave name [%s]: ", defaultName)
}
if r == nil {
return defaultName, nil
}
line, err := bufio.NewReader(r).ReadString('\n')
if err != nil {
if !errors.Is(err, io.EOF) {
return "", err
}
if line == "" {
return defaultName, nil
}
}
name := strings.TrimSpace(line)
if name == "" {
return defaultName, nil
}
return name, nil
}
Loading
Loading