Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,7 @@ color = "auto"
[daemon]
enabled = true
idle_unload_after = "30m"
host = "127.0.0.1" # loopback only; remote hosts are rejected

[cache]
enabled = true
Expand All @@ -469,6 +470,8 @@ model = "gpt-4o-mini"

Project-level `.intentrc` (TOML) overrides global config for invocations whose cwd is at or below the file's directory.

For `llamafile-local`, the daemon-managed model HTTP endpoint is loopback-only by contract. `daemon.host` may be omitted or set to a loopback value such as `127.0.0.1`, `localhost`, or `::1`; non-loopback hosts are rejected.

## 9. Update channel

- `stable`: tagged releases of the form `vMAJOR.MINOR.PATCH`.
Expand Down
10 changes: 3 additions & 7 deletions internal/cli/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,9 @@ func buildBackend(name string, cfg *config.Config, modelOverride string) (model.
// fall back to the mock backend so `i hello` doesn't hard-fail
// for a brand-new install — instead the mock returns an honest
// "the local model isn't installed yet" response.
host := cfg.Raw["daemon.host"]
if host == "" {
host = "127.0.0.1"
}
port := cfg.Raw["daemon.port"]
if port == "" {
port = "18080"
host, port, err := resolveLocalDaemonEndpoint(cfg)
if err != nil {
return nil, false, err
}
endpoint := fmt.Sprintf("http://%s:%s", host, port)
if !endpointReachable(endpoint) {
Expand Down
17 changes: 17 additions & 0 deletions internal/cli/backend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,23 @@ func TestBuildBackend_LlamafileLocalFallsBackWhenUnreachable(t *testing.T) {
}
}

func TestBuildBackend_LlamafileLocalRejectsNonLoopbackHost(t *testing.T) {
clearBackendEnv(t)
cfg := minimalConfig()
cfg.Raw["daemon.host"] = "0.0.0.0"

_, isFallback, err := buildBackend("llamafile-local", cfg, "")
if err == nil {
t.Fatal("expected error for non-loopback daemon host, got nil")
}
if isFallback {
t.Fatal("invalid daemon host should not silently fall back to mock")
}
if !strings.Contains(err.Error(), "loopback only") {
t.Fatalf("error = %q, want loopback hint", err)
}
}

func TestBuildBackend_UnknownBackendErrors(t *testing.T) {
clearBackendEnv(t)
_, _, err := buildBackend("nonexistent", minimalConfig(), "")
Expand Down
14 changes: 14 additions & 0 deletions internal/cli/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ func cmdConfig(_ context.Context, args []string) int {
errf("usage: i config set <key> <value>")
return 1
}
if err := validateConfigValue(args[1], args[2]); err != nil {
errf("config: %v", err)
return 1
}
cfg, err := config.Load(dirs.ConfigPath())
if err != nil {
errf("config: %v", err)
Expand All @@ -81,6 +85,16 @@ func cmdConfig(_ context.Context, args []string) int {
}
}

func validateConfigValue(key, value string) error {
switch key {
case "daemon.host":
_, err := normalizeLocalDaemonHost(value)
return err
default:
return nil
}
}

func lookupKnown(c *config.Config, key string) string {
switch key {
case "backend":
Expand Down
13 changes: 5 additions & 8 deletions internal/cli/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,11 @@ func daemonRunForeground(ctx context.Context, dirs state.Dirs, cfg *config.Confi
if id == "" {
id = models.DefaultID
}
host, port, err := resolveLocalDaemonEndpoint(cfg)
if err != nil {
errf("daemon: %v", err)
return 1
}
m := cat.Get(id)
if m == nil {
errf("daemon: current model %q not in catalog; run `i model list` and `i model use <id>`", id)
Expand All @@ -178,16 +183,8 @@ func daemonRunForeground(ctx context.Context, dirs state.Dirs, cfg *config.Confi
}
defer logF.Close()

port := cfg.Raw["daemon.port"]
if port == "" {
port = "18080"
}
portNum := 18080
fmt.Sscanf(port, "%d", &portNum)
host := cfg.Raw["daemon.host"]
if host == "" {
host = "127.0.0.1"
}

launcher := daemon.NewLauncher(mgr.LlamafilePath(), modelPath, host, portNum)
launcher.StdoutLog = logF
Expand Down
57 changes: 57 additions & 0 deletions internal/cli/daemon_host.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package cli

import (
"fmt"
"net"
"strings"

"github.com/CoreyRDean/intent/internal/config"
)

const defaultLocalDaemonHost = "127.0.0.1"
const defaultLocalDaemonPort = "18080"

// normalizeLocalDaemonHost accepts only loopback hosts for the local daemon.
// Any accepted value is canonicalized to 127.0.0.1 so the local backend never
// accidentally exposes the model server on a broader interface.
func normalizeLocalDaemonHost(raw string) (string, error) {
host := strings.TrimSpace(raw)
if host == "" {
return defaultLocalDaemonHost, nil
}
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
host = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]")
}
if strings.EqualFold(host, "localhost") {
return defaultLocalDaemonHost, nil
}
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
return defaultLocalDaemonHost, nil
}
return "", fmt.Errorf("daemon.host %q must resolve to loopback only", strings.TrimSpace(raw))
}

func resolveLocalDaemonHost(cfg *config.Config) (string, error) {
if cfg == nil {
return normalizeLocalDaemonHost("")
}
return normalizeLocalDaemonHost(cfg.Raw["daemon.host"])
}

func resolveLocalDaemonPort(cfg *config.Config) string {
if cfg == nil {
return defaultLocalDaemonPort
}
if port := strings.TrimSpace(cfg.Raw["daemon.port"]); port != "" {
return port
}
return defaultLocalDaemonPort
}

func resolveLocalDaemonEndpoint(cfg *config.Config) (host, port string, err error) {
host, err = resolveLocalDaemonHost(cfg)
if err != nil {
return "", "", err
}
return host, resolveLocalDaemonPort(cfg), nil
}
75 changes: 75 additions & 0 deletions internal/cli/daemon_host_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package cli

import (
"strings"
"testing"

"github.com/CoreyRDean/intent/internal/config"
)

func TestNormalizeLocalDaemonHost(t *testing.T) {
tests := []struct {
name string
raw string
want string
wantErr string
}{
{name: "default empty host", raw: "", want: "127.0.0.1"},
{name: "localhost", raw: "localhost", want: "127.0.0.1"},
{name: "ipv4 loopback", raw: "127.0.0.1", want: "127.0.0.1"},
{name: "ipv6 loopback", raw: "::1", want: "127.0.0.1"},
{name: "bracketed ipv6 loopback", raw: "[::1]", want: "127.0.0.1"},
{name: "non-loopback wildcard rejected", raw: "0.0.0.0", wantErr: "loopback only"},
{name: "non-loopback ip rejected", raw: "192.168.1.10", wantErr: "loopback only"},
{name: "hostname rejected", raw: "example.com", wantErr: "loopback only"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := normalizeLocalDaemonHost(tt.raw)
if tt.wantErr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want substring %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Fatalf("host = %q, want %q", got, tt.want)
}
})
}
}

func TestResolveLocalDaemonEndpoint(t *testing.T) {
cfg := &config.Config{Raw: map[string]string{
"daemon.host": " localhost ",
"daemon.port": " 19090 ",
}}

host, port, err := resolveLocalDaemonEndpoint(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "127.0.0.1" {
t.Fatalf("host = %q, want %q", host, "127.0.0.1")
}
if port != "19090" {
t.Fatalf("port = %q, want %q", port, "19090")
}
}

func TestValidateConfigValueRejectsRemoteDaemonHost(t *testing.T) {
err := validateConfigValue("daemon.host", "0.0.0.0")
if err == nil {
t.Fatal("expected daemon.host validation error, got nil")
}
if !strings.Contains(err.Error(), "loopback only") {
t.Fatalf("error = %q, want loopback hint", err)
}
}
21 changes: 21 additions & 0 deletions internal/cli/smoke_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,27 @@ func TestConfigRoundTrip(t *testing.T) {
}
}

func TestConfigSetRejectsRemoteDaemonHost(t *testing.T) {
stateDir := t.TempDir()
cacheDir := t.TempDir()
baseEnv := []string{
"HOME=" + os.Getenv("HOME"),
"PATH=" + os.Getenv("PATH"),
"INTENT_STATE_DIR=" + stateDir,
"INTENT_CACHE_DIR=" + cacheDir,
}

cmd := exec.Command(testBinary, "config", "set", "daemon.host", "0.0.0.0")
cmd.Env = baseEnv
out, err := cmd.CombinedOutput()
if err == nil {
t.Fatal("expected config set daemon.host to fail, got nil error")
}
if !strings.Contains(string(out), "loopback only") {
t.Fatalf("expected loopback validation error, got %q", string(out))
}
}

func TestConfigPath(t *testing.T) {
stdout, _, exitCode := run(t, nil, "config", "path")
if exitCode != 0 {
Expand Down
Loading