@marcusrbrown/infra 0.13.0 → 0.13.2
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/dist/cli.js +4977 -0
- package/package.json +7 -3
- package/src/__snapshots__/cli.test.ts.snap +0 -219
- package/src/__test__/mcp-ctx-fixture.test.ts +0 -108
- package/src/__test__/mcp-ctx-fixture.ts +0 -104
- package/src/cli.test.ts +0 -66
- package/src/cli.ts +0 -43
- package/src/commands/cliproxy/config.test.ts +0 -475
- package/src/commands/cliproxy/config.ts +0 -238
- package/src/commands/cliproxy/deploy.test.ts +0 -181
- package/src/commands/cliproxy/deploy.ts +0 -157
- package/src/commands/cliproxy/host.test.ts +0 -73
- package/src/commands/cliproxy/host.ts +0 -31
- package/src/commands/cliproxy/index.ts +0 -21
- package/src/commands/cliproxy/keys.test.ts +0 -326
- package/src/commands/cliproxy/keys.ts +0 -201
- package/src/commands/cliproxy/login.test.ts +0 -415
- package/src/commands/cliproxy/login.ts +0 -131
- package/src/commands/cliproxy/models.test.ts +0 -592
- package/src/commands/cliproxy/models.ts +0 -237
- package/src/commands/cliproxy/open.test.ts +0 -132
- package/src/commands/cliproxy/open.ts +0 -78
- package/src/commands/cliproxy/setup/gh.test.ts +0 -218
- package/src/commands/cliproxy/setup/gh.ts +0 -250
- package/src/commands/cliproxy/setup/preview.test.ts +0 -159
- package/src/commands/cliproxy/setup/preview.ts +0 -41
- package/src/commands/cliproxy/setup/prompts.test.ts +0 -58
- package/src/commands/cliproxy/setup/prompts.ts +0 -99
- package/src/commands/cliproxy/setup/providers.test.ts +0 -245
- package/src/commands/cliproxy/setup/providers.ts +0 -136
- package/src/commands/cliproxy/setup/smoke-test.test.ts +0 -821
- package/src/commands/cliproxy/setup/smoke-test.ts +0 -223
- package/src/commands/cliproxy/setup/templates.test.ts +0 -357
- package/src/commands/cliproxy/setup/templates.ts +0 -145
- package/src/commands/cliproxy/setup/validation.test.ts +0 -567
- package/src/commands/cliproxy/setup/validation.ts +0 -209
- package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +0 -363
- package/src/commands/cliproxy/setup/workflow-analyzer.ts +0 -136
- package/src/commands/cliproxy/setup.test.ts +0 -2366
- package/src/commands/cliproxy/setup.ts +0 -809
- package/src/commands/cliproxy/shared.test.ts +0 -118
- package/src/commands/cliproxy/shared.ts +0 -84
- package/src/commands/cliproxy/status.test.ts +0 -1157
- package/src/commands/cliproxy/status.ts +0 -597
- package/src/commands/dashboard/deploy.test.ts +0 -215
- package/src/commands/dashboard/deploy.ts +0 -135
- package/src/commands/dashboard/host.test.ts +0 -79
- package/src/commands/dashboard/host.ts +0 -33
- package/src/commands/dashboard/index.ts +0 -13
- package/src/commands/dashboard/logs.test.ts +0 -317
- package/src/commands/dashboard/logs.ts +0 -180
- package/src/commands/dashboard/status.test.ts +0 -708
- package/src/commands/dashboard/status.ts +0 -293
- package/src/commands/gateway/backup.test.ts +0 -426
- package/src/commands/gateway/backup.ts +0 -205
- package/src/commands/gateway/deploy.test.ts +0 -491
- package/src/commands/gateway/deploy.ts +0 -173
- package/src/commands/gateway/host.test.ts +0 -89
- package/src/commands/gateway/host.ts +0 -33
- package/src/commands/gateway/index.ts +0 -17
- package/src/commands/gateway/logs.test.ts +0 -263
- package/src/commands/gateway/logs.ts +0 -160
- package/src/commands/gateway/restore.test.ts +0 -650
- package/src/commands/gateway/restore.ts +0 -308
- package/src/commands/gateway/status.test.ts +0 -755
- package/src/commands/gateway/status.ts +0 -255
- package/src/commands/keeweb/deploy.test.ts +0 -177
- package/src/commands/keeweb/deploy.ts +0 -191
- package/src/commands/keeweb/index.ts +0 -11
- package/src/commands/keeweb/open.test.ts +0 -54
- package/src/commands/keeweb/open.ts +0 -30
- package/src/commands/keeweb/status.test.ts +0 -509
- package/src/commands/keeweb/status.ts +0 -359
- package/src/commands/mcp.test.ts +0 -260
- package/src/commands/mcp.ts +0 -58
- package/src/commands/status.test.ts +0 -222
- package/src/commands/status.ts +0 -161
- package/src/commands/umami/deploy.test.ts +0 -202
- package/src/commands/umami/deploy.ts +0 -132
- package/src/commands/umami/host.test.ts +0 -79
- package/src/commands/umami/host.ts +0 -33
- package/src/commands/umami/index.ts +0 -13
- package/src/commands/umami/logs.test.ts +0 -191
- package/src/commands/umami/logs.ts +0 -163
- package/src/commands/umami/status.test.ts +0 -633
- package/src/commands/umami/status.ts +0 -285
- package/src/commands/vpn/client.test.ts +0 -949
- package/src/commands/vpn/client.ts +0 -467
- package/src/commands/vpn/deploy.test.ts +0 -208
- package/src/commands/vpn/deploy.ts +0 -143
- package/src/commands/vpn/host.test.ts +0 -89
- package/src/commands/vpn/host.ts +0 -36
- package/src/commands/vpn/index.ts +0 -15
- package/src/commands/vpn/logs.test.ts +0 -234
- package/src/commands/vpn/logs.ts +0 -136
- package/src/commands/vpn/peers.test.ts +0 -478
- package/src/commands/vpn/shared.test.ts +0 -454
- package/src/commands/vpn/shared.ts +0 -170
- package/src/commands/vpn/status.test.ts +0 -142
- package/src/commands/vpn/status.ts +0 -78
- package/src/conventions.test.ts +0 -1180
- package/src/lib/action-ctx.ts +0 -25
- package/src/lib/known-hosts.test.ts +0 -192
- package/src/lib/known-hosts.ts +0 -76
- package/src/lib/redact.ts +0 -21
- package/src/lib/ssh-identity.test.ts +0 -134
- package/src/lib/ssh-identity.ts +0 -76
- /package/{src/resources/known_hosts → dist/known_hosts-tk724cp0.} +0 -0
|
@@ -1,415 +0,0 @@
|
|
|
1
|
-
import type {SpawnFn} from './login'
|
|
2
|
-
|
|
3
|
-
import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
|
|
4
|
-
|
|
5
|
-
const envKeys = ['CLIPROXY_DOMAIN', 'HOME', 'PATH', 'SSH_AUTH_SOCK'] as const
|
|
6
|
-
|
|
7
|
-
type ManagedEnvKey = (typeof envKeys)[number]
|
|
8
|
-
|
|
9
|
-
let originalEnv: Partial<Record<ManagedEnvKey, string | undefined>>
|
|
10
|
-
let originalIsTTY: boolean | undefined
|
|
11
|
-
|
|
12
|
-
function restoreManagedEnv(): void {
|
|
13
|
-
for (const key of envKeys) {
|
|
14
|
-
const value = originalEnv[key]
|
|
15
|
-
|
|
16
|
-
if (value === undefined) {
|
|
17
|
-
delete process.env[key]
|
|
18
|
-
continue
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
process.env[key] = value
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
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)}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
return {spawnFn, calls}
|
|
34
|
-
}
|
|
35
|
-
|
|
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
|
-
}
|
|
40
|
-
|
|
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'
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
describe('cliproxy login', () => {
|
|
50
|
-
beforeEach(() => {
|
|
51
|
-
originalEnv = Object.fromEntries(envKeys.map(key => [key, process.env[key]]))
|
|
52
|
-
originalIsTTY = process.stdin.isTTY
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
afterEach(() => {
|
|
56
|
-
restoreManagedEnv()
|
|
57
|
-
Object.defineProperty(process.stdin, 'isTTY', {value: originalIsTTY, configurable: true})
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
describe('validation', () => {
|
|
61
|
-
it('rejects unsupported providers', async () => {
|
|
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.')
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
it('requires interactive terminal (checked before SSH_AUTH_SOCK)', async () => {
|
|
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')
|
|
78
|
-
})
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
describe('host resolution', () => {
|
|
82
|
-
it('uses CLIPROXY_DOMAIN env var', async () => {
|
|
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')
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
it('uses --host flag', async () => {
|
|
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
|
-
)
|
|
103
|
-
})
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
describe('unit: resolveHost', () => {
|
|
107
|
-
it('returns input when provided', async () => {
|
|
108
|
-
const {resolveHost} = await import('./login')
|
|
109
|
-
expect(resolveHost('test.example.com')).toBe('test.example.com')
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
it('falls back to CLIPROXY_DOMAIN', async () => {
|
|
113
|
-
process.env.CLIPROXY_DOMAIN = 'env.example.com'
|
|
114
|
-
const {resolveHost} = await import('./login')
|
|
115
|
-
expect(resolveHost()).toBe('env.example.com')
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
it('falls back to default host', async () => {
|
|
119
|
-
delete process.env.CLIPROXY_DOMAIN
|
|
120
|
-
const {resolveHost} = await import('./login')
|
|
121
|
-
expect(resolveHost()).toBe('cliproxy.fro.bot')
|
|
122
|
-
})
|
|
123
|
-
})
|
|
124
|
-
|
|
125
|
-
describe('unit: requireSshAuthSock', () => {
|
|
126
|
-
it('returns SSH_AUTH_SOCK when set', async () => {
|
|
127
|
-
process.env.SSH_AUTH_SOCK = '/tmp/test-agent.sock'
|
|
128
|
-
const {requireSshAuthSock} = await import('./login')
|
|
129
|
-
expect(requireSshAuthSock()).toBe('/tmp/test-agent.sock')
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
it('throws when SSH_AUTH_SOCK is not set', async () => {
|
|
133
|
-
delete process.env.SSH_AUTH_SOCK
|
|
134
|
-
const {requireSshAuthSock} = await import('./login')
|
|
135
|
-
expect(() => requireSshAuthSock()).toThrow('SSH_AUTH_SOCK is required')
|
|
136
|
-
})
|
|
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('host forwarding into ssh argv', () => {
|
|
243
|
-
it('forwards resolved host from CLIPROXY_DOMAIN env var into ssh argv', async () => {
|
|
244
|
-
const {cliproxyLoginAction} = await import('./login')
|
|
245
|
-
const {spawnFn, calls} = makeSpawnOk(0)
|
|
246
|
-
setValidEnv()
|
|
247
|
-
process.env.CLIPROXY_DOMAIN = 'env-host.example.com'
|
|
248
|
-
|
|
249
|
-
await cliproxyLoginAction('claude', {}, spawnFn)
|
|
250
|
-
|
|
251
|
-
expect(calls).toHaveLength(1)
|
|
252
|
-
expect(calls[0]!.cmd).toContain('root@env-host.example.com')
|
|
253
|
-
})
|
|
254
|
-
|
|
255
|
-
it('forwards resolved host from --host option into ssh argv', async () => {
|
|
256
|
-
const {cliproxyLoginAction} = await import('./login')
|
|
257
|
-
const {spawnFn, calls} = makeSpawnOk(0)
|
|
258
|
-
setValidEnv()
|
|
259
|
-
delete process.env.CLIPROXY_DOMAIN
|
|
260
|
-
|
|
261
|
-
await cliproxyLoginAction('claude', {host: 'flag-host.example.com'}, spawnFn)
|
|
262
|
-
|
|
263
|
-
expect(calls).toHaveLength(1)
|
|
264
|
-
expect(calls[0]!.cmd).toContain('root@flag-host.example.com')
|
|
265
|
-
})
|
|
266
|
-
})
|
|
267
|
-
|
|
268
|
-
describe('non-zero exit propagation', () => {
|
|
269
|
-
it('throws with exit code in message when remote ssh exits non-zero', async () => {
|
|
270
|
-
const {cliproxyLoginAction} = await import('./login')
|
|
271
|
-
const {spawnFn, calls} = makeSpawnOk(17)
|
|
272
|
-
setValidEnv()
|
|
273
|
-
|
|
274
|
-
await expect(cliproxyLoginAction('claude', {}, spawnFn)).rejects.toThrow(
|
|
275
|
-
'Remote login command failed with exit code 17',
|
|
276
|
-
)
|
|
277
|
-
expect(calls).toHaveLength(1)
|
|
278
|
-
})
|
|
279
|
-
|
|
280
|
-
it('codex non-zero exit: error message includes exit code and v6.10.9 version requirement', async () => {
|
|
281
|
-
const {cliproxyLoginAction} = await import('./login')
|
|
282
|
-
const {spawnFn} = makeSpawnOk(1)
|
|
283
|
-
setValidEnv()
|
|
284
|
-
|
|
285
|
-
const error = await cliproxyLoginAction('codex', {}, spawnFn).catch((error_: unknown) => error_)
|
|
286
|
-
expect(error).toBeInstanceOf(Error)
|
|
287
|
-
const message = (error as Error).message
|
|
288
|
-
expect(message).toMatch(/exit code 1/)
|
|
289
|
-
expect(message).toMatch(/v6\.10\.9/)
|
|
290
|
-
expect(message).toMatch(/cliproxy-provider-version-skew\.md/)
|
|
291
|
-
})
|
|
292
|
-
|
|
293
|
-
it('codex non-zero exit: error message does not bleed into claude non-zero exit', async () => {
|
|
294
|
-
const {cliproxyLoginAction} = await import('./login')
|
|
295
|
-
const {spawnFn} = makeSpawnOk(2)
|
|
296
|
-
setValidEnv()
|
|
297
|
-
|
|
298
|
-
const error = await cliproxyLoginAction('claude', {}, spawnFn).catch((error_: unknown) => error_)
|
|
299
|
-
expect(error).toBeInstanceOf(Error)
|
|
300
|
-
const message = (error as Error).message
|
|
301
|
-
expect(message).toMatch(/Remote login command failed with exit code 2/)
|
|
302
|
-
expect(message).not.toMatch(/v6\.10\.9/)
|
|
303
|
-
expect(message).not.toMatch(/cliproxy-provider-version-skew/)
|
|
304
|
-
})
|
|
305
|
-
})
|
|
306
|
-
|
|
307
|
-
describe('ssh spawn contract', () => {
|
|
308
|
-
it('happy path — claude: spawn argv and options match ssh boundary contract', async () => {
|
|
309
|
-
const {cliproxyLoginAction} = await import('./login')
|
|
310
|
-
const {spawnFn, calls} = makeSpawnOk(0)
|
|
311
|
-
setValidEnv()
|
|
312
|
-
process.env.SSH_AUTH_SOCK = '/tmp/contract-test.sock'
|
|
313
|
-
const capturedPath = process.env.PATH!
|
|
314
|
-
const capturedHome = process.env.HOME!
|
|
315
|
-
|
|
316
|
-
await cliproxyLoginAction('claude', {}, spawnFn)
|
|
317
|
-
|
|
318
|
-
expect(calls).toHaveLength(1)
|
|
319
|
-
const {cmd, opts} = calls[0]!
|
|
320
|
-
const spawnOpts = opts as Parameters<SpawnFn>[1]
|
|
321
|
-
|
|
322
|
-
// SSH flags
|
|
323
|
-
expect(cmd).toContain('-tt')
|
|
324
|
-
expect(cmd).toContain('BatchMode=yes')
|
|
325
|
-
expect(cmd).toContain('ConnectTimeout=10')
|
|
326
|
-
|
|
327
|
-
// stdio inheritance
|
|
328
|
-
expect(spawnOpts.stdin).toBe('inherit')
|
|
329
|
-
expect(spawnOpts.stdout).toBe('inherit')
|
|
330
|
-
expect(spawnOpts.stderr).toBe('inherit')
|
|
331
|
-
|
|
332
|
-
// forwarded env vars
|
|
333
|
-
expect(spawnOpts.env.PATH).toBe(capturedPath)
|
|
334
|
-
expect(spawnOpts.env.HOME).toBe(capturedHome)
|
|
335
|
-
expect(spawnOpts.env.SSH_AUTH_SOCK).toBe('/tmp/contract-test.sock')
|
|
336
|
-
})
|
|
337
|
-
|
|
338
|
-
it('happy path — codex: spawn argv and options match ssh boundary contract', async () => {
|
|
339
|
-
const {cliproxyLoginAction} = await import('./login')
|
|
340
|
-
const {spawnFn, calls} = makeSpawnOk(0)
|
|
341
|
-
setValidEnv()
|
|
342
|
-
process.env.SSH_AUTH_SOCK = '/tmp/contract-test.sock'
|
|
343
|
-
const capturedPath = process.env.PATH!
|
|
344
|
-
const capturedHome = process.env.HOME!
|
|
345
|
-
|
|
346
|
-
await cliproxyLoginAction('codex', {}, spawnFn)
|
|
347
|
-
|
|
348
|
-
expect(calls).toHaveLength(1)
|
|
349
|
-
const {cmd, opts} = calls[0]!
|
|
350
|
-
const spawnOpts = opts as Parameters<SpawnFn>[1]
|
|
351
|
-
|
|
352
|
-
// SSH flags
|
|
353
|
-
expect(cmd).toContain('-tt')
|
|
354
|
-
expect(cmd).toContain('BatchMode=yes')
|
|
355
|
-
expect(cmd).toContain('ConnectTimeout=10')
|
|
356
|
-
|
|
357
|
-
// stdio inheritance
|
|
358
|
-
expect(spawnOpts.stdin).toBe('inherit')
|
|
359
|
-
expect(spawnOpts.stdout).toBe('inherit')
|
|
360
|
-
expect(spawnOpts.stderr).toBe('inherit')
|
|
361
|
-
|
|
362
|
-
// forwarded env vars
|
|
363
|
-
expect(spawnOpts.env.PATH).toBe(capturedPath)
|
|
364
|
-
expect(spawnOpts.env.HOME).toBe(capturedHome)
|
|
365
|
-
expect(spawnOpts.env.SSH_AUTH_SOCK).toBe('/tmp/contract-test.sock')
|
|
366
|
-
})
|
|
367
|
-
})
|
|
368
|
-
|
|
369
|
-
describe('anti-phishing notice', () => {
|
|
370
|
-
let logLines: string[]
|
|
371
|
-
let originalLog: typeof console.log
|
|
372
|
-
|
|
373
|
-
beforeEach(() => {
|
|
374
|
-
logLines = []
|
|
375
|
-
originalLog = console.log
|
|
376
|
-
console.log = (...args: unknown[]) => {
|
|
377
|
-
logLines.push(args.map(String).join(' '))
|
|
378
|
-
}
|
|
379
|
-
})
|
|
380
|
-
|
|
381
|
-
afterEach(() => {
|
|
382
|
-
console.log = originalLog
|
|
383
|
-
})
|
|
384
|
-
|
|
385
|
-
it('codex: anti-phishing notice appears before spawn', async () => {
|
|
386
|
-
const {cliproxyLoginAction} = await import('./login')
|
|
387
|
-
const linesBeforeSpawn: string[] = []
|
|
388
|
-
let spawnCalled = false
|
|
389
|
-
|
|
390
|
-
const trackingSpawn: SpawnFn = (_cmd, _opts) => {
|
|
391
|
-
linesBeforeSpawn.push(...logLines)
|
|
392
|
-
spawnCalled = true
|
|
393
|
-
return {exited: Promise.resolve(0)}
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
setValidEnv()
|
|
397
|
-
await cliproxyLoginAction('codex', {}, trackingSpawn)
|
|
398
|
-
|
|
399
|
-
expect(spawnCalled).toBe(true)
|
|
400
|
-
const allOutput = linesBeforeSpawn.join('\n')
|
|
401
|
-
expect(allOutput).toMatch(/openai\.com/i)
|
|
402
|
-
})
|
|
403
|
-
|
|
404
|
-
it('claude: anti-phishing notice does NOT appear', async () => {
|
|
405
|
-
const {cliproxyLoginAction} = await import('./login')
|
|
406
|
-
const {spawnFn} = makeSpawnOk(0)
|
|
407
|
-
setValidEnv()
|
|
408
|
-
|
|
409
|
-
await cliproxyLoginAction('claude', {}, spawnFn)
|
|
410
|
-
|
|
411
|
-
const allOutput = logLines.join('\n')
|
|
412
|
-
expect(allOutput).not.toMatch(/openai\.com/)
|
|
413
|
-
})
|
|
414
|
-
})
|
|
415
|
-
})
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
import type {goke} from 'goke'
|
|
2
|
-
|
|
3
|
-
import {z} from 'zod'
|
|
4
|
-
|
|
5
|
-
import {validateCliproxyHost} from './host'
|
|
6
|
-
|
|
7
|
-
const DEFAULT_HOST = 'cliproxy.fro.bot'
|
|
8
|
-
const DEFAULT_REMOTE_USER = 'root'
|
|
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
|
-
|
|
26
|
-
export function resolveHost(input?: string): string {
|
|
27
|
-
const host = input ?? process.env.CLIPROXY_DOMAIN ?? DEFAULT_HOST
|
|
28
|
-
|
|
29
|
-
if (!host) {
|
|
30
|
-
throw new Error('CLIPROXY_DOMAIN is required. Pass --host or set CLIPROXY_DOMAIN.')
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
return host
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function requireSshAuthSock(): string {
|
|
37
|
-
const sshAuthSock = process.env.SSH_AUTH_SOCK
|
|
38
|
-
if (!sshAuthSock) {
|
|
39
|
-
throw new Error('SSH_AUTH_SOCK is required. Start ssh-agent and load your SSH key before running cliproxy login.')
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
return sshAuthSock
|
|
43
|
-
}
|
|
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
|
-
if (provider === 'codex') {
|
|
101
|
-
throw new Error(
|
|
102
|
-
`Remote login command failed with exit code ${exitCode}. ` +
|
|
103
|
-
`The most likely cause is that the droplet's CLIProxyAPI binary predates --codex-device-login support (requires v6.10.9+). ` +
|
|
104
|
-
`See docs/runbooks/cliproxy-provider-version-skew.md for diagnosis and remediation steps.`,
|
|
105
|
-
)
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
throw new Error(`Remote login command failed with exit code ${exitCode}`)
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
console.log('If an OAuth URL was printed above, open it in your browser to complete login.')
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
export function registerCliproxyLogin(cli: ReturnType<typeof goke>): void {
|
|
115
|
-
cli
|
|
116
|
-
.command(
|
|
117
|
-
'cliproxy login <provider>',
|
|
118
|
-
`Run provider login on the remote CLIProxyAPI host. Supported providers: ${SUPPORTED_PROVIDERS_DISPLAY}.`,
|
|
119
|
-
)
|
|
120
|
-
.option(
|
|
121
|
-
'--host [host]',
|
|
122
|
-
z
|
|
123
|
-
.string()
|
|
124
|
-
.describe('CLIProxyAPI droplet host for SSH execution. Falls back to CLIPROXY_DOMAIN or cliproxy.fro.bot.'),
|
|
125
|
-
)
|
|
126
|
-
.example('# Start Claude login flow on remote CLIProxyAPI instance')
|
|
127
|
-
.example('infra cliproxy login claude')
|
|
128
|
-
.example('# Start ChatGPT Pro login flow via device-code')
|
|
129
|
-
.example('infra cliproxy login codex')
|
|
130
|
-
.action((provider, options) => cliproxyLoginAction(provider, options))
|
|
131
|
-
}
|