@marcusrbrown/infra 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marcusrbrown/infra",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Infrastructure management CLI — deploy automation, health checks, and MCP bridge",
5
5
  "keywords": [
6
6
  "infra",
@@ -36,7 +36,7 @@
36
36
  "zod": "^4.3.6"
37
37
  },
38
38
  "devDependencies": {
39
- "@modelcontextprotocol/sdk": "^1.29.0",
39
+ "@modelcontextprotocol/sdk": "1.29.0",
40
40
  "yaml": "2.9.0"
41
41
  },
42
42
  "engines": {
@@ -71,7 +71,7 @@ Commands:
71
71
  --key [key] Management API key. Falls back to CLIPROXY_MANAGEMENT_KEY when omitted.
72
72
 
73
73
 
74
- cliproxy login <provider> Run provider login on the remote CLIProxyAPI host and print OAuth URL output.
74
+ cliproxy login <provider> Run provider login on the remote CLIProxyAPI host. Supported providers: claude, codex.
75
75
 
76
76
  --host [host] CLIProxyAPI droplet host for SSH execution. Falls back to CLIPROXY_DOMAIN or cliproxy.fro.bot.
77
77
 
@@ -86,6 +86,11 @@ Commands:
86
86
  --key [key] Existing CLIProxyAPI API key value. When provided, setup skips key creation and reuses this key for GitHub secrets.
87
87
  --repo [repo] Target GitHub repository in owner/repo format. Skips the repository prompt when provided.
88
88
  --harness [harness] Harness template to configure. Choose opencode, claude-code, or generic. Generic remains interactive-only.
89
+ --providers [providers] Comma-separated list of providers to enable. Supported values: anthropic, openai. Example: --providers anthropic,openai
90
+ --model [model] Override the default model. Must be provider-prefixed and lowercase. Examples: anthropic/claude-sonnet-4-6, openai/gpt-4o
91
+ --force Overwrite existing GitHub secrets and variables without prompting.
92
+ --dry-run Print the plan without applying any changes.
93
+ --verify-smoke Run a smoke test against the proxy after setup completes.
89
94
 
90
95
 
91
96
  gateway status Show operational health of the gateway deployment via docker compose ps.
@@ -0,0 +1,73 @@
1
+ import {describe, expect, it} from 'bun:test'
2
+
3
+ import {validateCliproxyHost} from './host'
4
+
5
+ // ─── validateCliproxyHost ─────────────────────────────────────────────────────
6
+
7
+ describe('validateCliproxyHost', () => {
8
+ // ── Valid inputs ────────────────────────────────────────────────────────────
9
+
10
+ it('accepts a standard FQDN', () => {
11
+ expect(() => validateCliproxyHost('cliproxy.fro.bot')).not.toThrow()
12
+ expect(validateCliproxyHost('cliproxy.fro.bot')).toBe('cliproxy.fro.bot')
13
+ })
14
+
15
+ it('accepts localhost', () => {
16
+ expect(() => validateCliproxyHost('localhost')).not.toThrow()
17
+ })
18
+
19
+ it('accepts an IPv4 address', () => {
20
+ expect(() => validateCliproxyHost('147.182.133.210')).not.toThrow()
21
+ })
22
+
23
+ it('accepts a single-character hostname', () => {
24
+ expect(() => validateCliproxyHost('a')).not.toThrow()
25
+ })
26
+
27
+ it('accepts a hostname with hyphens', () => {
28
+ expect(() => validateCliproxyHost('my-cliproxy.prod.example.com')).not.toThrow()
29
+ })
30
+
31
+ // ── Injection attacks ───────────────────────────────────────────────────────
32
+
33
+ it('rejects a leading-hyphen value (ProxyCommand injection vector)', () => {
34
+ expect(() => validateCliproxyHost('-oProxyCommand=evil')).toThrow('Invalid CLIPROXY_DOMAIN')
35
+ })
36
+
37
+ it('rejects a value with shell metacharacters (semicolon)', () => {
38
+ expect(() => validateCliproxyHost('cliproxy.fro.bot;rm -rf')).toThrow('Invalid CLIPROXY_DOMAIN')
39
+ })
40
+
41
+ it('rejects a value with shell metacharacters (backtick)', () => {
42
+ expect(() => validateCliproxyHost('cliproxy.fro.bot`id`')).toThrow('Invalid CLIPROXY_DOMAIN')
43
+ })
44
+
45
+ it('rejects a value with spaces', () => {
46
+ expect(() => validateCliproxyHost('cliproxy fro.bot')).toThrow('Invalid CLIPROXY_DOMAIN')
47
+ })
48
+
49
+ it('rejects a value with an at-sign', () => {
50
+ expect(() => validateCliproxyHost('user@cliproxy.fro.bot')).toThrow('Invalid CLIPROXY_DOMAIN')
51
+ })
52
+
53
+ // ── Empty / blank ───────────────────────────────────────────────────────────
54
+
55
+ it('rejects an empty string', () => {
56
+ expect(() => validateCliproxyHost('')).toThrow('Invalid CLIPROXY_DOMAIN')
57
+ })
58
+
59
+ // ── Error message sanitization ──────────────────────────────────────────────
60
+
61
+ it('truncates the invalid value in the error message to ~30 chars', () => {
62
+ const longMalicious = `-oProxyCommand=${'A'.repeat(100)}`
63
+ let message = ''
64
+ try {
65
+ validateCliproxyHost(longMalicious)
66
+ } catch (error) {
67
+ message = error instanceof Error ? error.message : String(error)
68
+ }
69
+ // The excerpt in the message should not exceed 30 chars of the original value
70
+ expect(message).toContain('Invalid CLIPROXY_DOMAIN')
71
+ expect(message.length).toBeLessThan(longMalicious.length + 50)
72
+ })
73
+ })
@@ -0,0 +1,31 @@
1
+ // ─── Cliproxy host validation ─────────────────────────────────────────────────
2
+ //
3
+ // Validates CLIPROXY_DOMAIN values before they are passed as ssh argv arguments.
4
+ // A value starting with `-` would be interpreted by ssh as an option flag,
5
+ // enabling ProxyCommand injection and local code execution.
6
+
7
+ const VALID_HOST_RE = /^[a-z\d][a-z\d.\-]*$/i
8
+
9
+ /**
10
+ * Validates a candidate CLIPROXY_DOMAIN value against a strict hostname allowlist.
11
+ *
12
+ * Accepts: hostnames, FQDNs, IPv4 addresses, `localhost`.
13
+ * Rejects: empty strings, values starting with `-`, and anything containing
14
+ * characters outside `[A-Za-z0-9.-]`.
15
+ *
16
+ * @throws {Error} with a sanitized excerpt of the invalid value.
17
+ * @returns The validated host string (unchanged).
18
+ */
19
+ export function validateCliproxyHost(host: string): string {
20
+ if (!host) {
21
+ throw new Error('Invalid CLIPROXY_DOMAIN: value is empty')
22
+ }
23
+
24
+ if (!VALID_HOST_RE.test(host)) {
25
+ // Truncate to 30 chars and strip non-printable bytes before echoing back
26
+ const excerpt = host.slice(0, 30).replaceAll(/[^\u0020-\u007E]/g, '?')
27
+ throw new Error(`Invalid CLIPROXY_DOMAIN: "${excerpt}" — must match ${String.raw`[A-Za-z0-9][A-Za-z0-9.\-]*`}`)
28
+ }
29
+
30
+ return host
31
+ }
@@ -1,13 +1,13 @@
1
- import {resolve} from 'node:path'
2
- import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
1
+ import type {SpawnFn} from './login'
3
2
 
4
- const cliDir = resolve(import.meta.dir, '../../..')
3
+ import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
5
4
 
6
5
  const envKeys = ['CLIPROXY_DOMAIN', 'HOME', 'PATH', 'SSH_AUTH_SOCK'] as const
7
6
 
8
7
  type ManagedEnvKey = (typeof envKeys)[number]
9
8
 
10
9
  let originalEnv: Partial<Record<ManagedEnvKey, string | undefined>>
10
+ let originalIsTTY: boolean | undefined
11
11
 
12
12
  function restoreManagedEnv(): void {
13
13
  for (const key of envKeys) {
@@ -22,80 +22,84 @@ function restoreManagedEnv(): void {
22
22
  }
23
23
  }
24
24
 
25
- async function runLoginCommand(
26
- args: string[],
27
- envOverrides: Partial<Record<ManagedEnvKey, string | undefined>> = {},
28
- ): Promise<{stdout: string; stderr: string; exitCode: number}> {
29
- const env = {...process.env}
30
-
31
- for (const [key, value] of Object.entries(envOverrides)) {
32
- if (value === undefined) {
33
- delete env[key]
34
- continue
35
- }
36
-
37
- env[key] = value
25
+ /** Minimal SpawnFn mock for inherit-stdio calls — records invocations and resolves with exitCode. */
26
+ function makeSpawnOk(exitCode = 0): {spawnFn: SpawnFn; calls: {cmd: string[]; opts: unknown}[]} {
27
+ const calls: {cmd: string[]; opts: unknown}[] = []
28
+ const spawnFn: SpawnFn = (cmd, opts) => {
29
+ calls.push({cmd, opts})
30
+ return {exited: Promise.resolve(exitCode)}
38
31
  }
39
32
 
40
- const proc = Bun.spawn(['bun', 'src/cli.ts', 'cliproxy', 'login', ...args], {
41
- cwd: cliDir,
42
- env: {
43
- ...env,
44
- HOME: env.HOME ?? '/tmp/test-home',
45
- NO_COLOR: '1',
46
- PATH: env.PATH ?? '/usr/bin:/bin',
47
- SSH_AUTH_SOCK: env.SSH_AUTH_SOCK ?? '/tmp/test-sock',
48
- },
49
- stdout: 'pipe',
50
- stderr: 'pipe',
51
- })
33
+ return {spawnFn, calls}
34
+ }
52
35
 
53
- const [stdout, stderr, exitCode] = await Promise.all([
54
- new Response(proc.stdout).text(),
55
- new Response(proc.stderr).text(),
56
- proc.exited,
57
- ])
36
+ // Helper: a SpawnFn that must never be called (proves provider check fires before spawn)
37
+ const neverSpawn: SpawnFn = () => {
38
+ throw new Error('spawn must not be called for invalid provider')
39
+ }
58
40
 
59
- return {stdout, stderr, exitCode}
41
+ // Helper: set up a valid TTY + env so spawn-path tests can reach the spawn call
42
+ function setValidEnv(): void {
43
+ Object.defineProperty(process.stdin, 'isTTY', {value: true, configurable: true})
44
+ process.env.SSH_AUTH_SOCK = '/tmp/test-agent.sock'
45
+ process.env.PATH = process.env.PATH ?? '/usr/bin'
46
+ process.env.HOME = process.env.HOME ?? '/root'
60
47
  }
61
48
 
62
49
  describe('cliproxy login', () => {
63
50
  beforeEach(() => {
64
51
  originalEnv = Object.fromEntries(envKeys.map(key => [key, process.env[key]]))
52
+ originalIsTTY = process.stdin.isTTY
65
53
  })
66
54
 
67
55
  afterEach(() => {
68
56
  restoreManagedEnv()
57
+ Object.defineProperty(process.stdin, 'isTTY', {value: originalIsTTY, configurable: true})
69
58
  })
70
59
 
71
60
  describe('validation', () => {
72
61
  it('rejects unsupported providers', async () => {
73
- const {stderr, exitCode} = await runLoginCommand(['openai'])
74
- expect(exitCode).not.toBe(0)
75
- expect(stderr).toContain('Unsupported provider')
76
- expect(stderr).toContain('only "claude" is supported')
62
+ const {cliproxyLoginAction} = await import('./login')
63
+ const {spawnFn} = makeSpawnOk()
64
+
65
+ // Provider check happens before TTY check — no need to set isTTY
66
+ await expect(cliproxyLoginAction('openai', {}, spawnFn)).rejects.toThrow('Unsupported provider')
67
+ await expect(cliproxyLoginAction('openai', {}, spawnFn)).rejects.toThrow('Supported: claude, codex.')
77
68
  })
78
69
 
79
70
  it('requires interactive terminal (checked before SSH_AUTH_SOCK)', async () => {
80
- const {stderr, exitCode} = await runLoginCommand(['claude'], {SSH_AUTH_SOCK: undefined})
81
- expect(exitCode).not.toBe(0)
82
- expect(stderr).toContain('interactive terminal')
71
+ const {cliproxyLoginAction} = await import('./login')
72
+ const {spawnFn} = makeSpawnOk()
73
+
74
+ // Simulate non-TTY environment (as subprocess tests did)
75
+ Object.defineProperty(process.stdin, 'isTTY', {value: false, configurable: true})
76
+
77
+ await expect(cliproxyLoginAction('claude', {}, spawnFn)).rejects.toThrow('interactive terminal')
83
78
  })
84
79
  })
85
80
 
86
81
  describe('host resolution', () => {
87
82
  it('uses CLIPROXY_DOMAIN env var', async () => {
88
- const {stderr, exitCode} = await runLoginCommand(['claude'], {
89
- CLIPROXY_DOMAIN: 'custom.host.example',
90
- })
91
- expect(exitCode).not.toBe(0)
92
- expect(stderr).toContain('interactive terminal')
83
+ const {cliproxyLoginAction} = await import('./login')
84
+ const {spawnFn} = makeSpawnOk()
85
+
86
+ Object.defineProperty(process.stdin, 'isTTY', {value: false, configurable: true})
87
+ process.env.CLIPROXY_DOMAIN = 'custom.host.example'
88
+
89
+ // TTY check fires before host resolution — error is about TTY
90
+ await expect(cliproxyLoginAction('claude', {}, spawnFn)).rejects.toThrow('interactive terminal')
93
91
  })
94
92
 
95
93
  it('uses --host flag', async () => {
96
- const {stderr, exitCode} = await runLoginCommand(['claude', '--host', 'custom.host.example'])
97
- expect(exitCode).not.toBe(0)
98
- expect(stderr).toContain('interactive terminal')
94
+ const {cliproxyLoginAction} = await import('./login')
95
+ const {spawnFn} = makeSpawnOk()
96
+
97
+ Object.defineProperty(process.stdin, 'isTTY', {value: false, configurable: true})
98
+
99
+ // TTY check fires before host resolution — error is about TTY
100
+ await expect(cliproxyLoginAction('claude', {host: 'custom.host.example'}, spawnFn)).rejects.toThrow(
101
+ 'interactive terminal',
102
+ )
99
103
  })
100
104
  })
101
105
 
@@ -131,4 +135,154 @@ describe('cliproxy login', () => {
131
135
  expect(() => requireSshAuthSock()).toThrow('SSH_AUTH_SOCK is required')
132
136
  })
133
137
  })
138
+
139
+ describe('provider flags', () => {
140
+ it('happy path — codex: spawn args contain --codex-device-login and --no-browser', async () => {
141
+ const {cliproxyLoginAction} = await import('./login')
142
+ const {spawnFn, calls} = makeSpawnOk(0)
143
+ setValidEnv()
144
+
145
+ await cliproxyLoginAction('codex', {}, spawnFn)
146
+
147
+ const cmd = calls[0]!.cmd
148
+ expect(cmd.join(' ')).toContain('--codex-device-login')
149
+ expect(cmd.join(' ')).toContain('--no-browser')
150
+ expect(cmd.join(' ')).not.toContain('--codex-login ')
151
+ })
152
+
153
+ it('happy path — claude regression: spawn args contain --claude-login and --no-browser', async () => {
154
+ const {cliproxyLoginAction} = await import('./login')
155
+ const {spawnFn, calls} = makeSpawnOk(0)
156
+ setValidEnv()
157
+
158
+ await cliproxyLoginAction('claude', {}, spawnFn)
159
+
160
+ const cmd = calls[0]!.cmd
161
+ expect(cmd.join(' ')).toContain('--claude-login')
162
+ expect(cmd.join(' ')).toContain('--no-browser')
163
+ })
164
+
165
+ it('error path — unknown provider "chatgpt": rejects with correct message, no spawn', async () => {
166
+ const {cliproxyLoginAction} = await import('./login')
167
+
168
+ await expect(cliproxyLoginAction('chatgpt', {}, neverSpawn)).rejects.toThrow(
169
+ 'Unsupported provider "chatgpt". Supported: claude, codex.',
170
+ )
171
+ })
172
+
173
+ it('error path — empty provider: rejects with correct message, no spawn', async () => {
174
+ const {cliproxyLoginAction} = await import('./login')
175
+
176
+ await expect(cliproxyLoginAction('', {}, neverSpawn)).rejects.toThrow(
177
+ 'Unsupported provider "". Supported: claude, codex.',
178
+ )
179
+ })
180
+
181
+ it('error path — malformed provider path traversal: rejects with correct message, no spawn', async () => {
182
+ const {cliproxyLoginAction} = await import('./login')
183
+
184
+ await expect(cliproxyLoginAction('../../../etc/passwd', {}, neverSpawn)).rejects.toThrow(
185
+ 'Unsupported provider "../../../etc/passwd". Supported: claude, codex.',
186
+ )
187
+ })
188
+
189
+ it('error path — prototype-chain bypass "__proto__": rejects with correct message, no spawn', async () => {
190
+ const {cliproxyLoginAction} = await import('./login')
191
+
192
+ await expect(cliproxyLoginAction('__proto__', {}, neverSpawn)).rejects.toThrow(
193
+ 'Unsupported provider "__proto__". Supported: claude, codex.',
194
+ )
195
+ })
196
+
197
+ it('error path — prototype-chain bypass "constructor": rejects with correct message, no spawn', async () => {
198
+ const {cliproxyLoginAction} = await import('./login')
199
+
200
+ await expect(cliproxyLoginAction('constructor', {}, neverSpawn)).rejects.toThrow(
201
+ 'Unsupported provider "constructor". Supported: claude, codex.',
202
+ )
203
+ })
204
+
205
+ it('error path — prototype-chain bypass "hasOwnProperty": rejects with correct message, no spawn', async () => {
206
+ const {cliproxyLoginAction} = await import('./login')
207
+
208
+ await expect(cliproxyLoginAction('hasOwnProperty', {}, neverSpawn)).rejects.toThrow(
209
+ 'Unsupported provider "hasOwnProperty". Supported: claude, codex.',
210
+ )
211
+ })
212
+ })
213
+
214
+ describe('host validation', () => {
215
+ it('rejects --host with a leading dash (ProxyCommand injection vector), no spawn', async () => {
216
+ const {cliproxyLoginAction} = await import('./login')
217
+ setValidEnv()
218
+
219
+ await expect(cliproxyLoginAction('codex', {host: '-oProxyCommand=evil'}, neverSpawn)).rejects.toThrow(
220
+ 'Invalid CLIPROXY_DOMAIN',
221
+ )
222
+ })
223
+
224
+ it('rejects CLIPROXY_DOMAIN env with a leading dash, no spawn', async () => {
225
+ const {cliproxyLoginAction} = await import('./login')
226
+ setValidEnv()
227
+ process.env.CLIPROXY_DOMAIN = '-oProxyCommand=evil'
228
+
229
+ await expect(cliproxyLoginAction('codex', {}, neverSpawn)).rejects.toThrow('Invalid CLIPROXY_DOMAIN')
230
+ })
231
+
232
+ it('rejects --host with shell metacharacters (semicolon), no spawn', async () => {
233
+ const {cliproxyLoginAction} = await import('./login')
234
+ setValidEnv()
235
+
236
+ await expect(cliproxyLoginAction('codex', {host: 'gateway.example.com;rm -rf'}, neverSpawn)).rejects.toThrow(
237
+ 'Invalid CLIPROXY_DOMAIN',
238
+ )
239
+ })
240
+ })
241
+
242
+ describe('anti-phishing notice', () => {
243
+ let logLines: string[]
244
+ let originalLog: typeof console.log
245
+
246
+ beforeEach(() => {
247
+ logLines = []
248
+ originalLog = console.log
249
+ console.log = (...args: unknown[]) => {
250
+ logLines.push(args.map(String).join(' '))
251
+ }
252
+ })
253
+
254
+ afterEach(() => {
255
+ console.log = originalLog
256
+ })
257
+
258
+ it('codex: anti-phishing notice appears before spawn', async () => {
259
+ const {cliproxyLoginAction} = await import('./login')
260
+ const linesBeforeSpawn: string[] = []
261
+ let spawnCalled = false
262
+
263
+ const trackingSpawn: SpawnFn = (_cmd, _opts) => {
264
+ linesBeforeSpawn.push(...logLines)
265
+ spawnCalled = true
266
+ return {exited: Promise.resolve(0)}
267
+ }
268
+
269
+ setValidEnv()
270
+ await cliproxyLoginAction('codex', {}, trackingSpawn)
271
+
272
+ expect(spawnCalled).toBe(true)
273
+ const allOutput = linesBeforeSpawn.join('\n')
274
+ expect(allOutput).toMatch(/openai\.com/i)
275
+ })
276
+
277
+ it('claude: anti-phishing notice does NOT appear', async () => {
278
+ const {cliproxyLoginAction} = await import('./login')
279
+ const {spawnFn} = makeSpawnOk(0)
280
+ setValidEnv()
281
+
282
+ await cliproxyLoginAction('claude', {}, spawnFn)
283
+
284
+ const allOutput = logLines.join('\n')
285
+ expect(allOutput).not.toMatch(/openai\.com/)
286
+ })
287
+ })
134
288
  })
@@ -2,9 +2,27 @@ import type {goke} from 'goke'
2
2
 
3
3
  import {z} from 'zod'
4
4
 
5
+ import {validateCliproxyHost} from './host'
6
+
5
7
  const DEFAULT_HOST = 'cliproxy.fro.bot'
6
8
  const DEFAULT_REMOTE_USER = 'root'
7
9
 
10
+ const PROVIDER_FLAGS = {
11
+ claude: '--claude-login',
12
+ codex: '--codex-device-login',
13
+ } as const satisfies Record<string, string>
14
+
15
+ const SUPPORTED_PROVIDERS_DISPLAY = Object.keys(PROVIDER_FLAGS).join(', ')
16
+
17
+ export type SpawnFn = (
18
+ cmd: string[],
19
+ opts: {env: Record<string, string>; stdin: 'inherit'; stdout: 'inherit'; stderr: 'inherit'},
20
+ ) => {exited: Promise<number>}
21
+
22
+ export interface LoginOptions {
23
+ host?: string
24
+ }
25
+
8
26
  export function resolveHost(input?: string): string {
9
27
  const host = input ?? process.env.CLIPROXY_DOMAIN ?? DEFAULT_HOST
10
28
 
@@ -24,11 +42,72 @@ export function requireSshAuthSock(): string {
24
42
  return sshAuthSock
25
43
  }
26
44
 
45
+ export async function cliproxyLoginAction(
46
+ provider: string,
47
+ options: LoginOptions,
48
+ spawnFn: SpawnFn = Bun.spawn,
49
+ ): Promise<void> {
50
+ if (!Object.prototype.hasOwnProperty.call(PROVIDER_FLAGS, provider)) {
51
+ throw new Error(`Unsupported provider "${provider}". Supported: ${SUPPORTED_PROVIDERS_DISPLAY}.`)
52
+ }
53
+ const providerFlag = PROVIDER_FLAGS[provider as keyof typeof PROVIDER_FLAGS]
54
+
55
+ if (!process.stdin.isTTY) {
56
+ throw new Error('cliproxy login requires an interactive terminal. Run from a shell with TTY attached.')
57
+ }
58
+
59
+ const host = validateCliproxyHost(resolveHost(options.host))
60
+ const sshAuthSock = requireSshAuthSock()
61
+ const path = process.env.PATH
62
+ const home = process.env.HOME
63
+
64
+ if (!path) {
65
+ throw new Error('PATH is required to invoke ssh')
66
+ }
67
+
68
+ if (!home) {
69
+ throw new Error('HOME is required to invoke ssh')
70
+ }
71
+
72
+ if (provider === 'codex') {
73
+ console.log()
74
+ console.log(' Verify the URL')
75
+ console.log(' ─────────────')
76
+ console.log(" Codex login uses OpenAI's device-code flow. The droplet will print a code")
77
+ console.log(' and a URL. Before entering the code, verify the URL points to openai.com —')
78
+ console.log(' only complete the flow on the official OpenAI domain.')
79
+ console.log()
80
+ }
81
+
82
+ const remoteCommand = `cd /opt/cliproxy && docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI --no-browser ${providerFlag}`
83
+
84
+ const child = spawnFn(
85
+ ['ssh', '-tt', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', `${DEFAULT_REMOTE_USER}@${host}`, remoteCommand],
86
+ {
87
+ env: {
88
+ PATH: path,
89
+ HOME: home,
90
+ SSH_AUTH_SOCK: sshAuthSock,
91
+ },
92
+ stdin: 'inherit',
93
+ stdout: 'inherit',
94
+ stderr: 'inherit',
95
+ },
96
+ )
97
+
98
+ const exitCode = await child.exited
99
+ if (exitCode !== 0) {
100
+ throw new Error(`Remote login command failed with exit code ${exitCode}`)
101
+ }
102
+
103
+ console.log('If an OAuth URL was printed above, open it in your browser to complete login.')
104
+ }
105
+
27
106
  export function registerCliproxyLogin(cli: ReturnType<typeof goke>): void {
28
107
  cli
29
108
  .command(
30
109
  'cliproxy login <provider>',
31
- 'Run provider login on the remote CLIProxyAPI host and print OAuth URL output.',
110
+ `Run provider login on the remote CLIProxyAPI host. Supported providers: ${SUPPORTED_PROVIDERS_DISPLAY}.`,
32
111
  )
33
112
  .option(
34
113
  '--host [host]',
@@ -38,59 +117,7 @@ export function registerCliproxyLogin(cli: ReturnType<typeof goke>): void {
38
117
  )
39
118
  .example('# Start Claude login flow on remote CLIProxyAPI instance')
40
119
  .example('infra cliproxy login claude')
41
- .action(async (provider, options) => {
42
- if (provider !== 'claude') {
43
- throw new Error(`Unsupported provider "${provider}". Currently only "claude" is supported.`)
44
- }
45
-
46
- if (!process.stdin.isTTY) {
47
- throw new Error('cliproxy login requires an interactive terminal. Run from a shell with TTY attached.')
48
- }
49
-
50
- const host = resolveHost(options.host)
51
- const sshAuthSock = requireSshAuthSock()
52
- const path = process.env.PATH
53
- const home = process.env.HOME
54
-
55
- if (!path) {
56
- throw new Error('PATH is required to invoke ssh')
57
- }
58
-
59
- if (!home) {
60
- throw new Error('HOME is required to invoke ssh')
61
- }
62
-
63
- const remoteCommand =
64
- 'cd /opt/cliproxy && docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI --no-browser --claude-login'
65
-
66
- const child = Bun.spawn(
67
- [
68
- 'ssh',
69
- '-tt',
70
- '-o',
71
- 'BatchMode=yes',
72
- '-o',
73
- 'ConnectTimeout=10',
74
- `${DEFAULT_REMOTE_USER}@${host}`,
75
- remoteCommand,
76
- ],
77
- {
78
- env: {
79
- PATH: path,
80
- HOME: home,
81
- SSH_AUTH_SOCK: sshAuthSock,
82
- },
83
- stdin: 'inherit',
84
- stdout: 'inherit',
85
- stderr: 'inherit',
86
- },
87
- )
88
-
89
- const exitCode = await child.exited
90
- if (exitCode !== 0) {
91
- throw new Error(`Remote login command failed with exit code ${exitCode}`)
92
- }
93
-
94
- console.log('If an OAuth URL was printed above, open it in your browser to complete login.')
95
- })
120
+ .example('# Start ChatGPT Pro login flow via device-code')
121
+ .example('infra cliproxy login codex')
122
+ .action((provider, options) => cliproxyLoginAction(provider, options))
96
123
  }
@@ -91,6 +91,26 @@ describe('cliproxy open', () => {
91
91
  })
92
92
  })
93
93
 
94
+ describe('host validation', () => {
95
+ it('rejects CLIPROXY_DOMAIN env with a leading dash (ProxyCommand injection vector)', async () => {
96
+ const {stderr, exitCode} = await runOpenCommand([], {CLIPROXY_DOMAIN: '-oProxyCommand=evil'})
97
+ expect(exitCode).not.toBe(0)
98
+ expect(stderr).toContain('Invalid CLIPROXY_DOMAIN')
99
+ })
100
+
101
+ it('rejects CLIPROXY_DOMAIN env with shell metacharacters (semicolon)', async () => {
102
+ const {stderr, exitCode} = await runOpenCommand([], {CLIPROXY_DOMAIN: 'gateway.example.com;rm -rf'})
103
+ expect(exitCode).not.toBe(0)
104
+ expect(stderr).toContain('Invalid CLIPROXY_DOMAIN')
105
+ })
106
+
107
+ it('rejects CLIPROXY_DOMAIN env with an at-sign', async () => {
108
+ const {stderr, exitCode} = await runOpenCommand([], {CLIPROXY_DOMAIN: 'user@cliproxy.fro.bot'})
109
+ expect(exitCode).not.toBe(0)
110
+ expect(stderr).toContain('Invalid CLIPROXY_DOMAIN')
111
+ })
112
+ })
113
+
94
114
  describe('unit: resolveHost', () => {
95
115
  it('returns input when provided', async () => {
96
116
  const {resolveHost} = await import('./open')
@@ -2,6 +2,8 @@ import type {goke} from 'goke'
2
2
 
3
3
  import {z} from 'zod'
4
4
 
5
+ import {validateCliproxyHost} from './host'
6
+
5
7
  const DEFAULT_HOST = 'cliproxy.fro.bot'
6
8
  const DEFAULT_REMOTE_USER = 'root'
7
9
 
@@ -27,11 +29,12 @@ export function registerCliproxyOpen(cli: ReturnType<typeof goke>): void {
27
29
  .example('# Open on a custom host')
28
30
  .example('infra cliproxy open --host custom.example.com')
29
31
  .action(async options => {
32
+ const host = validateCliproxyHost(resolveHost(options.host))
33
+
30
34
  if (!process.stdin.isTTY) {
31
35
  throw new Error('cliproxy open requires an interactive terminal. Run from a shell with TTY attached.')
32
36
  }
33
37
 
34
- const host = resolveHost(options.host)
35
38
  const path = process.env.PATH
36
39
  const home = process.env.HOME
37
40