Skip to content

Commit fe28c84

Browse files
authored
feat: configurable agent bind host (#166)
1 parent 88d3374 commit fe28c84

File tree

6 files changed

+181
-5
lines changed

6 files changed

+181
-5
lines changed

docs/docs/configuration/overview.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Location: `~/.config/perry/config.json`
2323
```json
2424
{
2525
"port": 7391,
26+
"host": "0.0.0.0",
2627
"credentials": {
2728
"env": {
2829
"ANTHROPIC_API_KEY": "sk-ant-...",
@@ -116,6 +117,27 @@ perry config agent myserver.tail1234.ts.net
116117

117118
If you run any `perry` command without configuring an agent (and no local agent is running), Perry will interactively prompt you for the agent hostname.
118119

120+
## Bind Host
121+
122+
The `host` setting controls which network interface the agent listens on.
123+
124+
| Value | Description |
125+
|-------|-------------|
126+
| `0.0.0.0` | All interfaces (default) — accessible from other devices on the network |
127+
| `127.0.0.1` | Localhost only — only accessible from this machine |
128+
| Custom IP | Bind to a specific network interface |
129+
130+
You can set this via:
131+
132+
- **Config file**: `"host": "127.0.0.1"` in `config.json`
133+
- **CLI flag**: `perry agent run --host 127.0.0.1`
134+
- **Environment variable**: `PERRY_HOST=127.0.0.1`
135+
- **Setup wizard**: `perry agent config` (Network step)
136+
137+
Priority: CLI flag > `PERRY_HOST` env var > config file > `0.0.0.0`
138+
139+
Use `127.0.0.1` if you only access Perry from the same machine, or if corporate security policies flag services listening on all interfaces. Use `0.0.0.0` if you need remote access (e.g., via Tailscale or from other machines on the LAN).
140+
119141
## Configuration Sections
120142

121143
- [Credentials](./credentials.md) - Env vars, files, and SSH keys

src/agent/run.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ function createAgentServer(
4545
configDir: string,
4646
config: AgentConfig,
4747
port: number,
48+
hostname: string,
4849
tailscale?: TailscaleInfo
4950
) {
5051
sessionManager.init(configDir);
@@ -120,7 +121,7 @@ function createAgentServer(
120121

121122
const server = Bun.serve<WebSocketData>({
122123
port,
123-
hostname: '::',
124+
hostname,
124125

125126
async fetch(req, server) {
126127
const url = new URL(req.url);
@@ -229,6 +230,7 @@ function createAgentServer(
229230

230231
export interface StartAgentOptions {
231232
port?: number;
233+
host?: string;
232234
configDir?: string;
233235
noHostAccess?: boolean;
234236
}
@@ -295,12 +297,14 @@ export async function startAgent(options: StartAgentOptions = {}): Promise<void>
295297
const port =
296298
options.port || parseInt(process.env.PERRY_PORT || '', 10) || config.port || DEFAULT_AGENT_PORT;
297299

300+
const host = options.host || process.env.PERRY_HOST || config.host || '0.0.0.0';
301+
298302
console.log(BANNER);
299303
console.log(` Documentation: https://gricha.github.io/perry/getting-started`);
300304
console.log(` Web UI: http://localhost:${port}`);
301305
console.log('');
302306
console.log(`[agent] Config directory: ${configDir}`);
303-
console.log(`[agent] Starting on port ${port}...`);
307+
console.log(`[agent] Binding to ${host}:${port}...`);
304308

305309
const tailscale = await getTailscaleStatus();
306310
let tailscaleServeActive = false;
@@ -340,7 +344,7 @@ export async function startAgent(options: StartAgentOptions = {}): Promise<void>
340344
let terminalHandler: TerminalHandler;
341345

342346
try {
343-
const result = createAgentServer(configDir, config, port, tailscaleInfo);
347+
const result = createAgentServer(configDir, config, port, host, tailscaleInfo);
344348
server = result.server;
345349
fileWatcher = result.fileWatcher;
346350
terminalHandler = result.terminalHandler;

src/cli/setup-wizard.tsx

Lines changed: 147 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,17 @@ import { loadAgentConfig, saveAgentConfig, getConfigDir, ensureConfigDir } from
66
import { discoverSSHKeys } from '../ssh';
77
import type { SSHKeyInfo } from '../shared/client-types';
88

9-
type Step = 'welcome' | 'auth' | 'github' | 'ssh' | 'tailscale' | 'complete';
9+
type Step = 'welcome' | 'auth' | 'github' | 'ssh' | 'tailscale' | 'network' | 'complete';
1010

11-
const STEPS: Step[] = ['welcome', 'auth', 'github', 'ssh', 'tailscale', 'complete'];
11+
const STEPS: Step[] = ['welcome', 'auth', 'github', 'ssh', 'tailscale', 'network', 'complete'];
1212

1313
interface WizardState {
1414
authToken: string;
1515
authTokenGenerated: boolean;
1616
githubToken: string;
1717
selectedSSHKeys: string[];
1818
tailscaleAuthKey: string;
19+
bindHost: string;
1920
}
2021

2122
function WelcomeStep({ onNext }: { onNext: () => void }) {
@@ -38,6 +39,7 @@ function WelcomeStep({ onNext }: { onNext: () => void }) {
3839
<Text>• Git access (GitHub token)</Text>
3940
<Text>• SSH keys for workspaces</Text>
4041
<Text>• Tailscale networking</Text>
42+
<Text>• Network bind host</Text>
4143
</Box>
4244
<Box marginTop={1}>
4345
<Text color="gray">Press Enter to continue...</Text>
@@ -246,6 +248,133 @@ function SSHKeySelectStep({
246248
);
247249
}
248250

251+
type BindHostOption = '0.0.0.0' | '127.0.0.1' | 'custom';
252+
253+
const BIND_HOST_OPTIONS: { value: BindHostOption; label: string; description: string }[] = [
254+
{
255+
value: '0.0.0.0',
256+
label: 'All interfaces (0.0.0.0)',
257+
description: 'Accessible from other devices on the network',
258+
},
259+
{
260+
value: '127.0.0.1',
261+
label: 'Localhost only (127.0.0.1)',
262+
description: 'Only accessible from this machine',
263+
},
264+
{ value: 'custom', label: 'Custom', description: 'Enter a custom hostname or IP' },
265+
];
266+
267+
function NetworkStep({
268+
value,
269+
onChange,
270+
onNext,
271+
onBack,
272+
}: {
273+
value: string;
274+
onChange: (value: string) => void;
275+
onNext: () => void;
276+
onBack: () => void;
277+
}) {
278+
const currentOption: BindHostOption =
279+
value === '0.0.0.0' ? '0.0.0.0' : value === '127.0.0.1' ? '127.0.0.1' : 'custom';
280+
const isCustom = currentOption === 'custom';
281+
const [highlighted, setHighlighted] = useState(
282+
Math.max(
283+
0,
284+
BIND_HOST_OPTIONS.findIndex((o) => o.value === currentOption)
285+
)
286+
);
287+
const [editingCustom, setEditingCustom] = useState(false);
288+
const [customValue, setCustomValue] = useState(isCustom ? value : '');
289+
290+
useInput((input, key) => {
291+
if (editingCustom) {
292+
if (key.return) {
293+
if (customValue.trim()) {
294+
onChange(customValue.trim());
295+
}
296+
setEditingCustom(false);
297+
} else if (key.escape) {
298+
setEditingCustom(false);
299+
}
300+
return;
301+
}
302+
303+
if (key.upArrow) {
304+
setHighlighted((h) => Math.max(0, h - 1));
305+
} else if (key.downArrow) {
306+
setHighlighted((h) => Math.min(BIND_HOST_OPTIONS.length - 1, h + 1));
307+
} else if (input === ' ' || key.return) {
308+
const selected = BIND_HOST_OPTIONS[highlighted];
309+
if (selected.value === 'custom') {
310+
setEditingCustom(true);
311+
} else {
312+
onChange(selected.value);
313+
if (key.return) {
314+
onNext();
315+
return;
316+
}
317+
}
318+
} else if (key.escape) {
319+
onBack();
320+
}
321+
});
322+
323+
return (
324+
<Box flexDirection="column" gap={1}>
325+
<Text bold>Bind Host</Text>
326+
<Text color="gray">
327+
Choose which network interface the agent listens on. Use localhost to restrict access to
328+
this machine only.
329+
</Text>
330+
<Box flexDirection="column" marginTop={1}>
331+
{BIND_HOST_OPTIONS.map((option, index) => (
332+
<Box key={option.value}>
333+
<Text color={highlighted === index ? 'cyan' : undefined}>
334+
<Text
335+
color={
336+
option.value === currentOption || (option.value === 'custom' && isCustom)
337+
? 'green'
338+
: 'gray'
339+
}
340+
>
341+
{option.value === currentOption || (option.value === 'custom' && isCustom)
342+
? '(*) '
343+
: '( ) '}
344+
</Text>
345+
<Text>{option.label}</Text>
346+
<Text color="gray">{option.description}</Text>
347+
</Text>
348+
</Box>
349+
))}
350+
</Box>
351+
{editingCustom && (
352+
<Box marginTop={1}>
353+
<Text>Host: </Text>
354+
<TextInput
355+
value={customValue}
356+
onChange={setCustomValue}
357+
placeholder="e.g. 192.168.1.100"
358+
/>
359+
</Box>
360+
)}
361+
{isCustom && !editingCustom && value && (
362+
<Box marginTop={1}>
363+
<Text>Current: </Text>
364+
<Text color="cyan">{value}</Text>
365+
</Box>
366+
)}
367+
<Box marginTop={1}>
368+
<Text color="gray">
369+
{editingCustom
370+
? 'Enter to confirm, Esc to cancel'
371+
: 'Space to select, Enter to continue, Esc to go back'}
372+
</Text>
373+
</Box>
374+
</Box>
375+
);
376+
}
377+
249378
function CompleteStep({ state, onFinish }: { state: WizardState; onFinish: () => void }) {
250379
useInput((_input, key) => {
251380
if (key.return) {
@@ -259,6 +388,8 @@ function CompleteStep({ state, onFinish }: { state: WizardState; onFinish: () =>
259388
if (state.selectedSSHKeys.length > 0)
260389
configured.push(`${state.selectedSSHKeys.length} SSH key(s)`);
261390
if (state.tailscaleAuthKey) configured.push('Tailscale');
391+
if (state.bindHost && state.bindHost !== '0.0.0.0')
392+
configured.push(`Bind host: ${state.bindHost}`);
262393

263394
return (
264395
<Box flexDirection="column" gap={1}>
@@ -310,6 +441,7 @@ function SetupWizard() {
310441
githubToken: '',
311442
selectedSSHKeys: [],
312443
tailscaleAuthKey: '',
444+
bindHost: '0.0.0.0',
313445
});
314446
const [saving, setSaving] = useState(false);
315447

@@ -331,6 +463,7 @@ function SetupWizard() {
331463
githubToken: config.agents?.github?.token || '',
332464
selectedSSHKeys: config.ssh?.global.copy || [],
333465
tailscaleAuthKey: config.tailscale?.authKey || '',
466+
bindHost: config.host || '0.0.0.0',
334467
}));
335468
};
336469
loadExisting().catch(() => {});
@@ -404,6 +537,10 @@ function SetupWizard() {
404537
};
405538
}
406539

540+
if (state.bindHost) {
541+
config.host = state.bindHost;
542+
}
543+
407544
await saveAgentConfig(config, configDir);
408545
} catch {
409546
// Ignore save errors - user can reconfigure later
@@ -472,6 +609,14 @@ function SetupWizard() {
472609
optional
473610
/>
474611
)}
612+
{step === 'network' && (
613+
<NetworkStep
614+
value={state.bindHost}
615+
onChange={(v) => setState((s) => ({ ...s, bindHost: v }))}
616+
onNext={nextStep}
617+
onBack={prevStep}
618+
/>
619+
)}
475620
{step === 'complete' && (
476621
<CompleteStep
477622
state={state}

src/config/loader.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export async function ensureConfigDir(configDir?: string): Promise<void> {
1818
export function createDefaultAgentConfig(): AgentConfig {
1919
return {
2020
port: DEFAULT_AGENT_PORT,
21+
host: '0.0.0.0',
2122
credentials: {
2223
env: {},
2324
files: {},
@@ -79,6 +80,7 @@ export async function loadAgentConfig(configDir?: string): Promise<AgentConfig>
7980
: config.tailscale;
8081
return {
8182
port: config.port || DEFAULT_AGENT_PORT,
83+
host: config.host || '0.0.0.0',
8284
credentials: {
8385
env: config.credentials?.env || {},
8486
files: config.credentials?.files || {},

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,13 @@ agentCmd
4545
.command('run')
4646
.description('Start the agent daemon')
4747
.option('-p, --port <port>', 'Port to listen on', parseInt)
48+
.option('--host <host>', 'Host to bind to')
4849
.option('-c, --config-dir <dir>', 'Configuration directory')
4950
.option('--no-host-access', 'Disable direct host machine access')
5051
.action(async (options) => {
5152
await startAgent({
5253
port: options.port,
54+
host: options.host,
5355
configDir: options.configDir,
5456
noHostAccess: options.hostAccess === false,
5557
});

src/shared/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ export interface McpServerDefinition {
108108

109109
export interface AgentConfig {
110110
port: number;
111+
host?: string;
111112
credentials: WorkspaceCredentials;
112113
scripts: WorkspaceScripts;
113114
agents?: CodingAgents;

0 commit comments

Comments
 (0)