Skip to content

Commit be32ba3

Browse files
committed
login-bridge
1 parent 9e729e9 commit be32ba3

6 files changed

Lines changed: 254 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ All notable changes to Construct CLI will be documented in this file.
1010
- Enabled by default, configurable via `propagate_git_identity` in `config.toml`.
1111
- Safely injects values as `GIT_AUTHOR_NAME`, `GIT_AUTHOR_EMAIL`, etc., without mounting potentially incompatible host git configs.
1212
- **Improved Shell Prompt**: Container hostname is now set to `sandbox` (was random ID) for a cleaner prompt experience: `construct@sandbox:/workspace$`.
13+
- **Headless Login Bridge**: New `construct sys login-bridge` command to enable local browser login callbacks for headless-unfriendly agents (Codex, OpenCode with OpenAI GPT or Google Gemini).
14+
- Runs until interrupted and forwards `localhost` OAuth callbacks into the container.
1315

1416
## [0.7.0] - 2025-12-24
1517

@@ -44,7 +46,7 @@ All notable changes to Construct CLI will be documented in this file.
4446
- Automatic permission fixing (0600) and matching `.pub`/`known_hosts` support.
4547
- Smart logic to skip selection if only one key is found.
4648
- **Config Restoration**: New `construct sys restore-config` command to immediately recover from configuration backups.
47-
- **Shell Productivity Enhancements**:
49+
- **Shell Productivity Enhancements**:
4850
- Automatic management of `.bash_aliases` inside the container.
4951
- Standard aliases included: `ll`, `la`, `l`, and color-coded `ls`/`grep`.
5052
- Zsh-like navigation shortcuts: `..`, `...`, `....`.

cmd/construct/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ func main() {
123123
fmt.Println(" doctor # Check system health")
124124
fmt.Println(" ssh-import # Import SSH keys from host into The Construct (for when no SSH Agent is in use)")
125125
fmt.Println(" restore-config # Restore config from backup")
126+
fmt.Println(" login-bridge # Start a temporary localhost login callback bridge for headless-unfriendly agents")
126127
os.Exit(1)
127128
}
128129
handleSysCommand(args[1:], cfg)
@@ -264,6 +265,8 @@ func handleSysCommand(args []string, cfg *config.Config) {
264265
sys.SSHImport()
265266
case "restore-config":
266267
sys.RestoreConfig()
268+
case "login-bridge":
269+
sys.LoginBridge(args[1:])
267270
default:
268271
fmt.Printf("Unknown system command: %s\n", args[0])
269272
fmt.Println("Run 'construct sys' for a list of available commands.")

internal/agent/runner.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import (
44
"fmt"
55
"os"
66
"os/exec"
7+
"path/filepath"
78
stdruntime "runtime"
9+
"sort"
10+
"strconv"
811
"strings"
912

1013
"github.com/EstebanForge/construct-cli/internal/clipboard"
@@ -16,6 +19,10 @@ import (
1619
"github.com/EstebanForge/construct-cli/internal/ui"
1720
)
1821

22+
const defaultLoginForwardPorts = "1455,8085"
23+
const loginForwardListenOffset = 10000
24+
const loginBridgeFlagFile = ".login_bridge"
25+
1926
// RunWithArgs executes an agent inside the container with optional network override.
2027
func RunWithArgs(args []string, networkFlag string) {
2128
cfg, _, err := config.Load()
@@ -307,11 +314,18 @@ func runWithProviderEnv(args []string, cfg *config.Config, containerRuntime, con
307314
}
308315

309316
var cmd *exec.Cmd
317+
loginForward, loginPorts := shouldEnableLoginForward(args)
310318

311319
// Build the run command based on runtime
312320
if containerRuntime == "docker" {
313321
if _, err := exec.LookPath("docker-compose"); err == nil {
314322
runArgs := append(composeArgs, "run", "--rm")
323+
if loginForward {
324+
for _, port := range loginPorts {
325+
listenPort := port + loginForwardListenOffset
326+
runArgs = append(runArgs, "-p", fmt.Sprintf("127.0.0.1:%d:%d", port, listenPort))
327+
}
328+
}
315329
// Add host.docker.internal for Linux
316330
if stdruntime.GOOS == "linux" {
317331
runArgs = append(runArgs, "--add-host", "host.docker.internal:host-gateway")
@@ -328,6 +342,11 @@ func runWithProviderEnv(args []string, cfg *config.Config, containerRuntime, con
328342
break
329343
}
330344
}
345+
if loginForward {
346+
runArgs = append(runArgs, "-e", "CONSTRUCT_LOGIN_FORWARD=1")
347+
runArgs = append(runArgs, "-e", "CONSTRUCT_LOGIN_FORWARD_PORTS="+formatPorts(loginPorts))
348+
runArgs = append(runArgs, "-e", fmt.Sprintf("CONSTRUCT_LOGIN_FORWARD_LISTEN_OFFSET=%d", loginForwardListenOffset))
349+
}
331350
// Inject provider env vars
332351
for _, envVar := range providerEnv {
333352
runArgs = append(runArgs, "-e", envVar)
@@ -339,6 +358,12 @@ func runWithProviderEnv(args []string, cfg *config.Config, containerRuntime, con
339358
runArgs := []string{"compose"}
340359
runArgs = append(runArgs, composeArgs...)
341360
runArgs = append(runArgs, "run", "--rm")
361+
if loginForward {
362+
for _, port := range loginPorts {
363+
listenPort := port + loginForwardListenOffset
364+
runArgs = append(runArgs, "-p", fmt.Sprintf("127.0.0.1:%d:%d", port, listenPort))
365+
}
366+
}
342367
// Add host.docker.internal for Linux
343368
if stdruntime.GOOS == "linux" {
344369
runArgs = append(runArgs, "--add-host", "host.docker.internal:host-gateway")
@@ -355,6 +380,11 @@ func runWithProviderEnv(args []string, cfg *config.Config, containerRuntime, con
355380
break
356381
}
357382
}
383+
if loginForward {
384+
runArgs = append(runArgs, "-e", "CONSTRUCT_LOGIN_FORWARD=1")
385+
runArgs = append(runArgs, "-e", "CONSTRUCT_LOGIN_FORWARD_PORTS="+formatPorts(loginPorts))
386+
runArgs = append(runArgs, "-e", fmt.Sprintf("CONSTRUCT_LOGIN_FORWARD_LISTEN_OFFSET=%d", loginForwardListenOffset))
387+
}
358388
// Inject provider env vars
359389
for _, envVar := range providerEnv {
360390
runArgs = append(runArgs, "-e", envVar)
@@ -365,6 +395,12 @@ func runWithProviderEnv(args []string, cfg *config.Config, containerRuntime, con
365395
}
366396
} else if containerRuntime == "podman" {
367397
runArgs := append(composeArgs, "run", "--rm")
398+
if loginForward {
399+
for _, port := range loginPorts {
400+
listenPort := port + loginForwardListenOffset
401+
runArgs = append(runArgs, "-p", fmt.Sprintf("127.0.0.1:%d:%d", port, listenPort))
402+
}
403+
}
368404
// Add host.docker.internal for Linux
369405
if stdruntime.GOOS == "linux" {
370406
runArgs = append(runArgs, "--add-host", "host.docker.internal:host-gateway")
@@ -381,6 +417,11 @@ func runWithProviderEnv(args []string, cfg *config.Config, containerRuntime, con
381417
break
382418
}
383419
}
420+
if loginForward {
421+
runArgs = append(runArgs, "-e", "CONSTRUCT_LOGIN_FORWARD=1")
422+
runArgs = append(runArgs, "-e", "CONSTRUCT_LOGIN_FORWARD_PORTS="+formatPorts(loginPorts))
423+
runArgs = append(runArgs, "-e", fmt.Sprintf("CONSTRUCT_LOGIN_FORWARD_LISTEN_OFFSET=%d", loginForwardListenOffset))
424+
}
384425
// Inject provider env vars
385426
for _, envVar := range providerEnv {
386427
runArgs = append(runArgs, "-e", envVar)
@@ -392,6 +433,12 @@ func runWithProviderEnv(args []string, cfg *config.Config, containerRuntime, con
392433
runArgs := []string{"compose"}
393434
runArgs = append(runArgs, composeArgs...)
394435
runArgs = append(runArgs, "run", "--rm")
436+
if loginForward {
437+
for _, port := range loginPorts {
438+
listenPort := port + loginForwardListenOffset
439+
runArgs = append(runArgs, "-p", fmt.Sprintf("127.0.0.1:%d:%d", port, listenPort))
440+
}
441+
}
395442
// Add host.docker.internal for Linux
396443
if stdruntime.GOOS == "linux" {
397444
runArgs = append(runArgs, "--add-host", "host.docker.internal:host-gateway")
@@ -408,6 +455,11 @@ func runWithProviderEnv(args []string, cfg *config.Config, containerRuntime, con
408455
break
409456
}
410457
}
458+
if loginForward {
459+
runArgs = append(runArgs, "-e", "CONSTRUCT_LOGIN_FORWARD=1")
460+
runArgs = append(runArgs, "-e", "CONSTRUCT_LOGIN_FORWARD_PORTS="+formatPorts(loginPorts))
461+
runArgs = append(runArgs, "-e", fmt.Sprintf("CONSTRUCT_LOGIN_FORWARD_LISTEN_OFFSET=%d", loginForwardListenOffset))
462+
}
411463
// Inject provider env vars
412464
for _, envVar := range providerEnv {
413465
runArgs = append(runArgs, "-e", envVar)
@@ -439,3 +491,71 @@ func runWithProviderEnv(args []string, cfg *config.Config, containerRuntime, con
439491
os.Exit(1)
440492
}
441493
}
494+
495+
func shouldEnableLoginForward(args []string) (bool, []int) {
496+
if ports, ok := readLoginBridgePorts(); ok {
497+
return true, ports
498+
}
499+
for _, arg := range args {
500+
if arg == "login" || arg == "auth" {
501+
ports := parsePorts(defaultLoginForwardPorts)
502+
if len(ports) == 0 {
503+
return true, []int{1455}
504+
}
505+
return true, ports
506+
}
507+
}
508+
return false, nil
509+
}
510+
511+
func readLoginBridgePorts() ([]int, bool) {
512+
path := filepath.Join(config.GetConfigDir(), loginBridgeFlagFile)
513+
data, err := os.ReadFile(path)
514+
if err != nil {
515+
return nil, false
516+
}
517+
raw := strings.TrimSpace(string(data))
518+
ports := parsePorts(raw)
519+
if len(ports) == 0 {
520+
ports = parsePorts(defaultLoginForwardPorts)
521+
}
522+
if len(ports) == 0 {
523+
return []int{1455}, true
524+
}
525+
return ports, true
526+
}
527+
528+
func parsePorts(raw string) []int {
529+
parts := strings.FieldsFunc(raw, func(r rune) bool {
530+
return r == ',' || r == ' ' || r == '\t' || r == '\n'
531+
})
532+
seen := make(map[int]struct{})
533+
ports := make([]int, 0, len(parts))
534+
for _, part := range parts {
535+
if part == "" {
536+
continue
537+
}
538+
port, err := strconv.Atoi(part)
539+
if err != nil || port <= 0 {
540+
continue
541+
}
542+
if _, exists := seen[port]; exists {
543+
continue
544+
}
545+
seen[port] = struct{}{}
546+
ports = append(ports, port)
547+
}
548+
sort.Ints(ports)
549+
return ports
550+
}
551+
552+
func formatPorts(ports []int) string {
553+
if len(ports) == 0 {
554+
return ""
555+
}
556+
parts := make([]string, 0, len(ports))
557+
for _, port := range ports {
558+
parts = append(parts, strconv.Itoa(port))
559+
}
560+
return strings.Join(parts, ",")
561+
}

internal/sys/login_bridge.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package sys
2+
3+
import (
4+
"bufio"
5+
"flag"
6+
"fmt"
7+
"os"
8+
"os/signal"
9+
"path/filepath"
10+
"sort"
11+
"strconv"
12+
"strings"
13+
"syscall"
14+
15+
"github.com/EstebanForge/construct-cli/internal/config"
16+
)
17+
18+
const defaultLoginBridgePorts = "1455,8085"
19+
const loginBridgeFlagFile = ".login_bridge"
20+
21+
// LoginBridge enables localhost login callback forwarding until interrupted.
22+
func LoginBridge(args []string) {
23+
fs := flag.NewFlagSet("login-bridge", flag.ContinueOnError)
24+
fs.SetOutput(os.Stdout)
25+
ports := fs.String("ports", defaultLoginBridgePorts, "Comma-separated callback ports for login flows")
26+
27+
if err := fs.Parse(args); err != nil {
28+
fmt.Println("Usage: construct sys login-bridge [--ports 1455,8085]")
29+
return
30+
}
31+
32+
normalized := normalizePortList(*ports)
33+
if normalized == "" {
34+
normalized = defaultLoginBridgePorts
35+
}
36+
37+
if err := os.MkdirAll(config.GetConfigDir(), 0755); err != nil {
38+
fmt.Fprintf(os.Stderr, "Error: Failed to create config directory: %v\n", err)
39+
return
40+
}
41+
42+
flagPath := filepath.Join(config.GetConfigDir(), loginBridgeFlagFile)
43+
if err := os.WriteFile(flagPath, []byte(fmt.Sprintf("%s\n", normalized)), 0644); err != nil {
44+
fmt.Fprintf(os.Stderr, "Error: Failed to enable login bridge: %v\n", err)
45+
return
46+
}
47+
defer func() {
48+
if err := os.Remove(flagPath); err != nil && !os.IsNotExist(err) {
49+
fmt.Fprintf(os.Stderr, "Warning: Failed to remove login bridge flag: %v\n", err)
50+
}
51+
}()
52+
53+
fmt.Printf("Login bridge active on localhost ports: %s\n", normalized)
54+
fmt.Println("Run your agent login in another terminal window.")
55+
fmt.Println("Press Enter to stop the bridge.")
56+
57+
signalChan := make(chan os.Signal, 1)
58+
signal.Notify(signalChan, os.Interrupt, syscall.SIGTERM)
59+
60+
inputChan := make(chan struct{}, 1)
61+
go func() {
62+
reader := bufio.NewReader(os.Stdin)
63+
if _, err := reader.ReadString('\n'); err != nil {
64+
fmt.Fprintf(os.Stderr, "Warning: Failed to read input: %v\n", err)
65+
}
66+
inputChan <- struct{}{}
67+
}()
68+
69+
select {
70+
case <-signalChan:
71+
case <-inputChan:
72+
}
73+
74+
fmt.Println("Login bridge stopped.")
75+
}
76+
77+
func normalizePortList(raw string) string {
78+
ports := parsePorts(raw)
79+
if len(ports) == 0 {
80+
return ""
81+
}
82+
parts := make([]string, 0, len(ports))
83+
for _, port := range ports {
84+
parts = append(parts, fmt.Sprintf("%d", port))
85+
}
86+
return strings.Join(parts, ",")
87+
}
88+
89+
func parsePorts(raw string) []int {
90+
parts := strings.FieldsFunc(raw, func(r rune) bool {
91+
return r == ',' || r == ' ' || r == '\t' || r == '\n'
92+
})
93+
seen := make(map[int]struct{})
94+
ports := make([]int, 0, len(parts))
95+
for _, part := range parts {
96+
if part == "" {
97+
continue
98+
}
99+
port, err := strconv.Atoi(part)
100+
if err != nil || port <= 0 {
101+
continue
102+
}
103+
if _, exists := seen[port]; exists {
104+
continue
105+
}
106+
seen[port] = struct{}{}
107+
ports = append(ports, port)
108+
}
109+
sort.Ints(ports)
110+
return ports
111+
}

internal/templates/entrypoint.sh

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ if [ ! -f "$MARKER_FILE" ]; then
7272
ninja gradle \
7373
fastmod shellcheck yamllint terraform awscli \
7474
node@24 python@3 oven-sh/bun/bun jq \
75-
vite webpack tlrc || true
75+
vite webpack tlrc socat || true
7676

7777
# Install AI agents via Homebrew
7878
echo "Installing gemini-cli..."
@@ -213,6 +213,21 @@ patch_agent_code() {
213213
}
214214
patch_agent_code
215215

216+
# Forward localhost login callbacks to the container when requested.
217+
if [ "$CONSTRUCT_LOGIN_FORWARD" = "1" ] && command -v socat >/dev/null; then
218+
LOGIN_PORTS="${CONSTRUCT_LOGIN_FORWARD_PORTS:-1455}"
219+
LISTEN_OFFSET="${CONSTRUCT_LOGIN_FORWARD_LISTEN_OFFSET:-10000}"
220+
IFS=', ' read -r -a LOGIN_PORT_LIST <<< "$LOGIN_PORTS"
221+
for LOGIN_PORT in "${LOGIN_PORT_LIST[@]}"; do
222+
if [ -z "$LOGIN_PORT" ]; then
223+
continue
224+
fi
225+
LISTEN_PORT=$((LOGIN_PORT + LISTEN_OFFSET))
226+
socat "TCP-LISTEN:${LISTEN_PORT},fork,bind=0.0.0.0" "TCP:127.0.0.1:${LOGIN_PORT}" >/tmp/login-forward.log 2>&1 &
227+
echo "✓ Started login callback forwarder on port ${LISTEN_PORT} -> ${LOGIN_PORT}"
228+
done
229+
fi
230+
216231
# Debug: Check if command exists before exec
217232
if [ $# -gt 0 ]; then
218233
if ! command -v "$1" &> /dev/null; then

internal/ui/help.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ Global Flags:
4040
construct sys doctor # Check system health
4141
construct sys ssh-import # Import SSH keys from host into The Construct (for when no SSH Agent is in use)
4242
construct sys restore-config # Restore config from backup
43+
construct sys login-bridge # Start a temporary localhost login callback bridge for headless-unfriendly agents
4344
4445
[network] Network Management:
4546
construct network allow api.anthropic.com # Add domain to allowlist

0 commit comments

Comments
 (0)