fix(launcher): restore network address startup logging (#2619)
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
package launcher
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
networkInterfacesFn = net.Interfaces
|
||||
networkInterfaceAddrsFn = func(iface net.Interface) ([]net.Addr, error) { return iface.Addrs() }
|
||||
)
|
||||
|
||||
func listHostNetworkAddresses() []string {
|
||||
interfaces, err := networkInterfacesFn()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var addresses []net.Addr
|
||||
for _, iface := range interfaces {
|
||||
if iface.Flags&net.FlagUp == 0 {
|
||||
continue
|
||||
}
|
||||
addrs, err := networkInterfaceAddrsFn(iface)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
addresses = append(addresses, addrs...)
|
||||
}
|
||||
return networkAddressesFromAddrs(addresses)
|
||||
}
|
||||
|
||||
func networkAddressesFromAddrs(addresses []net.Addr) []string {
|
||||
var ipv4, ipv6 []string
|
||||
seen := make(map[string]struct{}, len(addresses))
|
||||
for _, address := range addresses {
|
||||
ip := networkAddrIP(address)
|
||||
if ip == nil || ip.IsLoopback() || ip.IsLinkLocalUnicast() {
|
||||
continue
|
||||
}
|
||||
host := ip.String()
|
||||
if _, ok := seen[host]; ok {
|
||||
continue
|
||||
}
|
||||
seen[host] = struct{}{}
|
||||
if ip.To4() != nil {
|
||||
ipv4 = append(ipv4, host)
|
||||
} else {
|
||||
ipv6 = append(ipv6, host)
|
||||
}
|
||||
}
|
||||
return append(ipv4, ipv6...)
|
||||
}
|
||||
|
||||
func networkAddrIP(address net.Addr) net.IP {
|
||||
switch value := address.(type) {
|
||||
case *net.IPNet:
|
||||
return value.IP
|
||||
case *net.IPAddr:
|
||||
return value.IP
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func networkURLsForPort(port int, hosts []string) []string {
|
||||
urls := make([]string, 0, len(hosts))
|
||||
for _, host := range hosts {
|
||||
urls = append(urls, fmt.Sprintf("http://%s", net.JoinHostPort(host, strconv.Itoa(port))))
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
func networkAddressesForBindHost(addresses []string, bindHost string) []string {
|
||||
if strings.TrimSpace(bindHost) == "" {
|
||||
return addresses
|
||||
}
|
||||
|
||||
allowed := make(map[string]struct{})
|
||||
for _, rawHost := range strings.Split(bindHost, ",") {
|
||||
host := strings.TrimSpace(rawHost)
|
||||
if host == "" {
|
||||
continue
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
if ip.IsUnspecified() {
|
||||
return addresses
|
||||
}
|
||||
if !ip.IsLoopback() && !ip.IsLinkLocalUnicast() {
|
||||
allowed[ip.String()] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
filtered := make([]string, 0, len(allowed))
|
||||
for _, address := range addresses {
|
||||
if _, ok := allowed[address]; ok {
|
||||
filtered = append(filtered, address)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package launcher
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLogStartupPrintsNetworkAddress(t *testing.T) {
|
||||
t.Setenv("KANDEV_SERVER_HOST", "")
|
||||
oldNetworkInterfaces := networkInterfacesFn
|
||||
oldNetworkInterfaceAddrs := networkInterfaceAddrsFn
|
||||
t.Cleanup(func() {
|
||||
networkInterfacesFn = oldNetworkInterfaces
|
||||
networkInterfaceAddrsFn = oldNetworkInterfaceAddrs
|
||||
})
|
||||
networkInterfacesFn = func() ([]net.Interface, error) {
|
||||
return []net.Interface{{Name: "eth0", Flags: net.FlagUp}, {Name: "tailscale0", Flags: net.FlagUp}}, nil
|
||||
}
|
||||
networkInterfaceAddrsFn = func(iface net.Interface) ([]net.Addr, error) {
|
||||
switch iface.Name {
|
||||
case "eth0":
|
||||
return []net.Addr{testNetworkAddr(t, "192.168.1.34/22")}, nil
|
||||
case "tailscale0":
|
||||
return []net.Addr{testNetworkAddr(t, "100.94.173.104/32")}, nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
output := captureLauncherStdout(t, func() {
|
||||
logStartup("test", portConfig{
|
||||
BackendPort: 38429,
|
||||
BackendURL: "http://localhost:38429",
|
||||
}, "", "")
|
||||
})
|
||||
|
||||
for _, want := range []string{
|
||||
"[kandev] network: http://192.168.1.34:38429",
|
||||
"[kandev] network: http://100.94.173.104:38429",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("startup output = %q, want %q", output, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListHostNetworkAddressesFiltersAndOrders(t *testing.T) {
|
||||
addresses := []net.Addr{
|
||||
testNetworkAddr(t, "127.0.0.1/8"),
|
||||
testNetworkAddr(t, "169.254.10.5/16"),
|
||||
testNetworkAddr(t, "2001:db8::1/64"),
|
||||
testNetworkAddr(t, "192.168.1.34/22"),
|
||||
testNetworkAddr(t, "100.94.173.104/32"),
|
||||
testNetworkAddr(t, "192.168.1.34/22"),
|
||||
testNetworkAddr(t, "fe80::1/64"),
|
||||
testNetworkAddr(t, "::1/128"),
|
||||
}
|
||||
|
||||
got := networkAddressesFromAddrs(addresses)
|
||||
want := []string{"192.168.1.34", "100.94.173.104", "2001:db8::1"}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("networkAddressesFromAddrs() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListHostNetworkAddressesSkipsDownInterfaces(t *testing.T) {
|
||||
oldNetworkInterfaces := networkInterfacesFn
|
||||
oldNetworkInterfaceAddrs := networkInterfaceAddrsFn
|
||||
t.Cleanup(func() {
|
||||
networkInterfacesFn = oldNetworkInterfaces
|
||||
networkInterfaceAddrsFn = oldNetworkInterfaceAddrs
|
||||
})
|
||||
networkInterfacesFn = func() ([]net.Interface, error) {
|
||||
return []net.Interface{
|
||||
{Name: "eth0", Flags: net.FlagUp},
|
||||
{Name: "down0"},
|
||||
}, nil
|
||||
}
|
||||
networkInterfaceAddrsFn = func(iface net.Interface) ([]net.Addr, error) {
|
||||
if iface.Name == "down0" {
|
||||
return []net.Addr{testNetworkAddr(t, "10.0.0.99/24")}, nil
|
||||
}
|
||||
return []net.Addr{testNetworkAddr(t, "192.168.1.34/22")}, nil
|
||||
}
|
||||
|
||||
got := listHostNetworkAddresses()
|
||||
want := []string{"192.168.1.34"}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("listHostNetworkAddresses() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkURLsForPortFormatsIPv6(t *testing.T) {
|
||||
hosts := []string{"192.168.1.34", "100.94.173.104", "2001:db8::1"}
|
||||
got := networkURLsForPort(38429, hosts)
|
||||
want := []string{
|
||||
"http://192.168.1.34:38429",
|
||||
"http://100.94.173.104:38429",
|
||||
"http://[2001:db8::1]:38429",
|
||||
}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("networkURLsForPort() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkAddressesForBindHost(t *testing.T) {
|
||||
addresses := []string{"192.168.1.34", "100.94.173.104", "2001:db8::1"}
|
||||
tests := []struct {
|
||||
name string
|
||||
bindHost string
|
||||
want []string
|
||||
}{
|
||||
{name: "wildcard default", bindHost: "", want: addresses},
|
||||
{name: "ipv4 wildcard", bindHost: "0.0.0.0", want: addresses},
|
||||
{name: "ipv6 wildcard", bindHost: "::", want: addresses},
|
||||
{name: "loopback", bindHost: "127.0.0.1"},
|
||||
{name: "specific address", bindHost: "192.168.1.34", want: []string{"192.168.1.34"}},
|
||||
{name: "multiple addresses", bindHost: "127.0.0.1,100.94.173.104", want: []string{"100.94.173.104"}},
|
||||
{name: "hostname loopback", bindHost: "localhost"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got := networkAddressesForBindHost(addresses, test.bindHost)
|
||||
if strings.Join(got, ",") != strings.Join(test.want, ",") {
|
||||
t.Fatalf("networkAddressesForBindHost(%q) = %v, want %v", test.bindHost, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogStartupKeepsLocalhostWhenNetworkDiscoveryFails(t *testing.T) {
|
||||
t.Setenv("KANDEV_SERVER_HOST", "")
|
||||
oldNetworkInterfaces := networkInterfacesFn
|
||||
oldNetworkInterfaceAddrs := networkInterfaceAddrsFn
|
||||
t.Cleanup(func() {
|
||||
networkInterfacesFn = oldNetworkInterfaces
|
||||
networkInterfaceAddrsFn = oldNetworkInterfaceAddrs
|
||||
})
|
||||
networkInterfacesFn = func() ([]net.Interface, error) {
|
||||
return nil, errors.New("test interface enumeration failure")
|
||||
}
|
||||
|
||||
output := captureLauncherStdout(t, func() {
|
||||
logStartup("test", portConfig{
|
||||
BackendPort: 38429,
|
||||
BackendURL: "http://localhost:38429",
|
||||
}, "", "")
|
||||
})
|
||||
if !strings.Contains(output, "[kandev] url: http://localhost:38429") {
|
||||
t.Fatalf("startup output = %q, missing localhost URL", output)
|
||||
}
|
||||
if strings.Contains(output, "network:") {
|
||||
t.Fatalf("startup output = %q, want no network lines", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogStartupSuppressesNetworkAddressesForLoopbackBind(t *testing.T) {
|
||||
oldNetworkInterfaces := networkInterfacesFn
|
||||
oldNetworkInterfaceAddrs := networkInterfaceAddrsFn
|
||||
t.Cleanup(func() {
|
||||
networkInterfacesFn = oldNetworkInterfaces
|
||||
networkInterfaceAddrsFn = oldNetworkInterfaceAddrs
|
||||
})
|
||||
t.Setenv("KANDEV_SERVER_HOST", "127.0.0.1")
|
||||
networkInterfacesFn = func() ([]net.Interface, error) {
|
||||
return []net.Interface{{Name: "eth0", Flags: net.FlagUp}}, nil
|
||||
}
|
||||
networkInterfaceAddrsFn = func(net.Interface) ([]net.Addr, error) {
|
||||
return []net.Addr{testNetworkAddr(t, "192.168.1.34/22")}, nil
|
||||
}
|
||||
|
||||
output := captureLauncherStdout(t, func() {
|
||||
logStartup("test", portConfig{
|
||||
BackendPort: 38429,
|
||||
BackendURL: "http://localhost:38429",
|
||||
}, "", "")
|
||||
})
|
||||
if strings.Contains(output, "network:") {
|
||||
t.Fatalf("startup output = %q, want no network lines for loopback bind", output)
|
||||
}
|
||||
}
|
||||
|
||||
func testNetworkAddr(t *testing.T, cidr string) net.Addr {
|
||||
t.Helper()
|
||||
ip, network, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
t.Fatalf("net.ParseCIDR(%q) = %v", cidr, err)
|
||||
}
|
||||
return &net.IPNet{IP: ip, Mask: network.Mask}
|
||||
}
|
||||
|
||||
func captureLauncherStdout(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
read, write, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("os.Pipe() = %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = read.Close()
|
||||
_ = write.Close()
|
||||
})
|
||||
oldStdout := os.Stdout
|
||||
os.Stdout = write
|
||||
defer func() { os.Stdout = oldStdout }()
|
||||
|
||||
fn()
|
||||
if err := write.Close(); err != nil {
|
||||
t.Fatalf("close stdout writer: %v", err)
|
||||
}
|
||||
var output bytes.Buffer
|
||||
if _, err := io.Copy(&output, read); err != nil {
|
||||
t.Fatalf("read captured stdout: %v", err)
|
||||
}
|
||||
if err := read.Close(); err != nil {
|
||||
t.Fatalf("close stdout reader: %v", err)
|
||||
}
|
||||
return output.String()
|
||||
}
|
||||
@@ -134,6 +134,10 @@ func runManagedApp(ctx context.Context, cfg managedAppConfig) int {
|
||||
func logStartup(header string, ports portConfig, dbPath, logLevel string) {
|
||||
fmt.Println("[kandev] " + header)
|
||||
fmt.Println("[kandev] url:", ports.BackendURL)
|
||||
hosts := networkAddressesForBindHost(listHostNetworkAddresses(), os.Getenv("KANDEV_SERVER_HOST"))
|
||||
for _, url := range networkURLsForPort(ports.BackendPort, hosts) {
|
||||
fmt.Println("[kandev] network:", url)
|
||||
}
|
||||
fmt.Println("[kandev] mcp:", ports.BackendURL+"/mcp")
|
||||
if dbPath != "" {
|
||||
fmt.Println("[kandev] db:", dbPath)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
spec: "../../specs/go-dev-launcher/spec.md"
|
||||
created: 2026-08-13
|
||||
status: implemented
|
||||
---
|
||||
|
||||
# Implementation Plan: Restore dev network address logging
|
||||
|
||||
## Overview
|
||||
|
||||
Restore the startup-banner behavior that the Node launcher provided before the
|
||||
`make dev` migration. Add a small Go network-address helper, feed its filtered
|
||||
addresses into the existing shared `logStartup` path, and cover formatting,
|
||||
filtering, and enumeration failures with focused launcher tests. This keeps the
|
||||
behavior shared by `dev`, `start`, and `run` without exposing the internal Vite or
|
||||
agentctl ports.
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
|
||||
### Host address discovery and startup banner
|
||||
|
||||
- Add `apps/backend/internal/launcher/network.go` with best-effort enumeration of
|
||||
active, non-loopback host addresses, deduplication, link-local filtering,
|
||||
IPv4-first ordering, IPv6 URL formatting, and filtering against an explicit
|
||||
bind host.
|
||||
- Update `apps/backend/internal/launcher/start.go` so `logStartup` prints the
|
||||
filtered network URLs for `portConfig.BackendPort` between the localhost URL and
|
||||
the existing MCP/DB/log-level lines.
|
||||
- Keep interface-enumeration errors non-fatal and leave the existing localhost
|
||||
startup path unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
- **What:** LAN and Tailscale-style IPv4 addresses are listed, duplicates and
|
||||
loopback/link-local addresses are omitted, IPv6 URLs are bracketed, and IPv4 is
|
||||
listed before IPv6.
|
||||
**File:** `apps/backend/internal/launcher/network_test.go`.
|
||||
**How:** Deterministic table-driven unit tests over representative interface
|
||||
address fixtures.
|
||||
- **What:** The shared startup banner emits the network URLs and still emits the
|
||||
localhost URL when interface enumeration fails.
|
||||
**File:** `apps/backend/internal/launcher/network_test.go`.
|
||||
**How:** Assert the generated startup output with injected address-discovery
|
||||
results and an enumeration-error case.
|
||||
- **What:** Down interfaces are not advertised, and explicit bind hosts only
|
||||
advertise reachable interface addresses.
|
||||
**File:** `apps/backend/internal/launcher/network_test.go`.
|
||||
**How:** Use active/down interface fixtures and loopback, wildcard, and
|
||||
specific-bind cases.
|
||||
|
||||
## Documentation
|
||||
|
||||
- Update `docs/public/cli.md` so the public CLI reference describes the optional
|
||||
`network:` lines and keeps the network-exposure warning clear. This page is a
|
||||
reference document.
|
||||
|
||||
## Verification Results
|
||||
|
||||
- `cd apps/backend && go test -run TestLogStartupPrintsNetworkAddress ./internal/launcher` —
|
||||
failed before the fix with the expected missing `network:` assertion.
|
||||
- `cd apps/backend && go test -run 'Test(ListHostNetworkAddresses|NetworkURLsForPort|LogStartup)' ./internal/launcher` —
|
||||
passed, 4 tests.
|
||||
- `cd apps/backend && go test ./internal/launcher` — passed, 196 tests.
|
||||
- `cd apps/backend && go test -run TestLogStartupSuppressesNetworkAddressesForLoopbackBind ./internal/launcher` —
|
||||
failed before the fix, then passed after bind-host filtering was added.
|
||||
- `cd apps/backend && go test -run 'Test(NetworkAddressesForBindHost|LogStartupSuppressesNetworkAddressesForLoopbackBind)' ./internal/launcher` — passed.
|
||||
- `cd apps/backend && go test -run TestListHostNetworkAddressesSkipsDownInterfaces ./internal/launcher` — passed.
|
||||
- `rtk git diff --check` — passed.
|
||||
- `node --test scripts/validate-public-docs.test.mjs` — passed, 61 tests.
|
||||
- `node scripts/validate-public-docs.mjs` — passed, 41 published docs pages.
|
||||
- Platform risk: native interface enumeration is covered by injected fixtures on this
|
||||
Linux runner; non-Linux native enumeration remains unverified.
|
||||
|
||||
## Implementation Waves And Parallel Candidates
|
||||
|
||||
Wave 1, sequential:
|
||||
|
||||
- [x] [task-01-restore-network-logging](task-01-restore-network-logging.md)
|
||||
|
||||
The implementation and its tests share the launcher output contract, so the task
|
||||
is intentionally sequential and is not a delegation authorization.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
id: "01-restore-network-logging"
|
||||
title: "Restore network startup logging"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/go-dev-launcher/spec.md"
|
||||
---
|
||||
|
||||
# Task 01: Restore network startup logging
|
||||
|
||||
Implement the startup-banner contract added to the Go dev-launcher spec.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `dev`, `start`, and `run` print a `network:` URL for each unique non-loopback,
|
||||
non-link-local host address on the backend port.
|
||||
- IPv4 addresses appear before IPv6 addresses, and IPv6 URLs use brackets.
|
||||
- Interface enumeration errors do not prevent startup or remove the localhost URL.
|
||||
- Explicit loopback or specific bind hosts do not advertise unrelated interfaces.
|
||||
- Internal Vite and agentctl ports remain absent from the network-address lines.
|
||||
|
||||
## Verification
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test -run 'Test(ListHostNetworkAddresses|NetworkURLsForPort|LogStartup)' ./internal/launcher
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/launcher/network.go`
|
||||
- `apps/backend/internal/launcher/network_test.go`
|
||||
- `apps/backend/internal/launcher/start.go`
|
||||
- `docs/public/cli.md`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. The helper, shared logger, and regression tests define one output
|
||||
contract.
|
||||
|
||||
## Inputs
|
||||
|
||||
- `docs/specs/go-dev-launcher/spec.md`, startup output section and scenarios.
|
||||
- The previous Node behavior in `apps/cli/src/shared.ts` at the pre-migration
|
||||
revision, especially `listHostNetworkAddresses`, `networkUrlsForPort`, and
|
||||
`logStartupInfo`.
|
||||
- The current shared Go logger in `apps/backend/internal/launcher/start.go`.
|
||||
|
||||
## Output contract
|
||||
|
||||
Summarize the changed files, exact test command and result, any remaining
|
||||
platform-specific risk, and synchronize this task and `plan.md` status.
|
||||
|
||||
## Results
|
||||
|
||||
- `cd apps/backend && go test -run TestLogStartupPrintsNetworkAddress ./internal/launcher` —
|
||||
failed before the fix with the expected missing `network:` assertion.
|
||||
- `cd apps/backend && go test -run 'Test(ListHostNetworkAddresses|NetworkURLsForPort|LogStartup)' ./internal/launcher` —
|
||||
passed, 4 tests.
|
||||
- `cd apps/backend && go test ./internal/launcher` — passed, 196 tests.
|
||||
- `cd apps/backend && go test -run TestLogStartupSuppressesNetworkAddressesForLoopbackBind ./internal/launcher` —
|
||||
failed before the bind-host fix, then passed after the fix.
|
||||
- `cd apps/backend && go test -run 'Test(NetworkAddressesForBindHost|LogStartupSuppressesNetworkAddressesForLoopbackBind)' ./internal/launcher` — passed.
|
||||
- `rtk git diff --check` — passed.
|
||||
- `gofmt -w apps/backend/internal/launcher/network.go apps/backend/internal/launcher/network_test.go apps/backend/internal/launcher/start.go` — completed successfully.
|
||||
- `node --test scripts/validate-public-docs.test.mjs` — passed, 61 tests.
|
||||
- `node scripts/validate-public-docs.mjs` — passed, 41 published docs pages.
|
||||
- Platform risk: native interface enumeration is covered by injected fixtures on this
|
||||
Linux runner; non-Linux native enumeration remains unverified.
|
||||
- Cleanup: no temporary files, processes, or external runtime state created.
|
||||
+3
-1
@@ -225,7 +225,9 @@ Supported actions are `install`, `uninstall`, `start`, `stop`, `restart`, `statu
|
||||
|
||||
There is no separate web-server port in an installed release: the backend serves embedded assets. For the `run`, `start`, and development launcher flows, if `--port`, `KANDEV_BACKEND_PORT`, or `KANDEV_PORT` specifies a port, the launcher checks it before declaring the backend ready, does not substitute another one, and fails startup if the configured listen address cannot bind it. `kandev service install --port` remains the separate installer behavior described above.
|
||||
|
||||
The launcher prints `http://localhost:<port>`, but the backend's default `server.host` is `0.0.0.0`. That can expose Kandev to other machines on the network, and the current local product path is not an authenticated multi-user boundary. Bind it to loopback unless remote access is deliberately protected:
|
||||
The launcher prints `http://localhost:<port>`. When it can enumerate non-loopback interfaces, it also prints `network:` URLs for each unique non-loopback, non-link-local address on the same port, including local-network and Tailscale addresses. IPv6 addresses are shown in brackets. If `KANDEV_SERVER_HOST` restricts the listener, the launcher only prints matching addresses. These lines are informational and are omitted if interface discovery is unavailable.
|
||||
|
||||
The backend's default `server.host` is `0.0.0.0`. That can expose Kandev to other machines on the network, and the current local product path is not an authenticated multi-user boundary. Bind it to loopback unless remote access is deliberately protected:
|
||||
|
||||
```bash
|
||||
KANDEV_SERVER_HOST=127.0.0.1 kandev
|
||||
|
||||
@@ -104,6 +104,19 @@ require the launcher health token — Vite is not a Kandev process and does not
|
||||
If either child exits, the launcher shuts the whole tree down and exits with the child's
|
||||
code, matching current behavior.
|
||||
|
||||
### Startup output advertises network addresses
|
||||
|
||||
The startup banner for `dev`, `start`, and `run` prints the localhost backend URL and
|
||||
then one `network:` URL for each unique non-loopback, non-link-local host address on the machine. This
|
||||
includes addresses from local-network and Tailscale interfaces so that a user running
|
||||
Kandev on a remote VM can identify an address to open from another machine.
|
||||
|
||||
The banner lists IPv4 addresses before IPv6 addresses, omits link-local addresses, and
|
||||
wraps IPv6 hosts in brackets. Network-address discovery is best effort: if the host
|
||||
cannot enumerate its interfaces, the launcher still starts and prints the localhost
|
||||
URL. When `KANDEV_SERVER_HOST` restricts the backend to loopback or specific addresses,
|
||||
the banner omits unreachable interface addresses and only advertises matching binds.
|
||||
|
||||
### Backend restart from the UI still rebuilds
|
||||
|
||||
The restart supervisor (ADR 0019) writes a launch manifest and listens on a control
|
||||
@@ -187,6 +200,7 @@ them: building and serving the web app (Vite), the published npm shim, and repo
|
||||
| Either child exits | Shut down the tree; exit with that child's code, or 0 if it was signalled. |
|
||||
| `pnpm` missing | Surface the spawn error rather than hanging on the readiness probe. |
|
||||
| Ctrl-C | Whole tree terminates, including on Windows, without winjob. |
|
||||
| Network interface enumeration fails | Continue startup and omit `network:` lines; keep the localhost URL. |
|
||||
|
||||
## Persistence guarantees
|
||||
|
||||
@@ -196,3 +210,18 @@ them: building and serving the web app (Vite), the published npm shim, and repo
|
||||
never prune other backup families.
|
||||
- The supervisor manifest and control socket live under
|
||||
`<KANDEV_HOME_DIR>/supervisor/` at mode `0600`, unchanged from `start`.
|
||||
|
||||
## Scenarios
|
||||
|
||||
- **GIVEN** a host with non-loopback LAN and Tailscale IPv4 addresses, **WHEN** the
|
||||
`dev`, `start`, or `run` launcher prints its startup banner, **THEN** it prints one
|
||||
`network:` URL for each address using the backend port.
|
||||
- **GIVEN** a host with duplicate addresses, loopback addresses, or link-local
|
||||
addresses, **WHEN** the startup banner is printed, **THEN** duplicate, loopback, and
|
||||
link-local addresses are omitted.
|
||||
- **GIVEN** a host with an IPv6 address, **WHEN** the startup banner is printed, **THEN**
|
||||
the address is rendered as `http://[<address>]:<port>`.
|
||||
- **GIVEN** interface enumeration returns an error, **WHEN** the launcher starts,
|
||||
**THEN** it continues startup and prints the localhost URL without a `network:` line.
|
||||
- **GIVEN** `KANDEV_SERVER_HOST` is a loopback or specific bind address, **WHEN** the
|
||||
startup banner is printed, **THEN** it omits unrelated network addresses.
|
||||
|
||||
Reference in New Issue
Block a user