@marcusrbrown/infra 0.4.11 → 0.6.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 +2 -1
- package/src/__snapshots__/cli.test.ts.snap +1 -1
- package/src/__test__/mcp-ctx-fixture.test.ts +108 -0
- package/src/__test__/mcp-ctx-fixture.ts +104 -0
- package/src/commands/cliproxy/config.test.ts +261 -91
- package/src/commands/cliproxy/config.ts +84 -41
- package/src/commands/cliproxy/host.test.ts +73 -0
- package/src/commands/cliproxy/host.ts +31 -0
- package/src/commands/cliproxy/keys.test.ts +326 -0
- package/src/commands/cliproxy/keys.ts +116 -60
- package/src/commands/cliproxy/login.test.ts +203 -49
- package/src/commands/cliproxy/login.ts +83 -56
- package/src/commands/cliproxy/open.test.ts +20 -0
- package/src/commands/cliproxy/open.ts +4 -1
- package/src/commands/cliproxy/status.test.ts +80 -0
- package/src/commands/cliproxy/status.ts +74 -54
- package/src/commands/gateway/backup.test.ts +66 -1
- package/src/commands/gateway/backup.ts +39 -24
- package/src/commands/gateway/status.test.ts +130 -1
- package/src/commands/gateway/status.ts +50 -39
- package/src/commands/keeweb/status.test.ts +146 -0
- package/src/commands/keeweb/status.ts +32 -32
- package/src/commands/mcp.test.ts +210 -0
- package/src/commands/mcp.ts +32 -3
- package/src/commands/status.test.ts +95 -111
- package/src/commands/status.ts +46 -28
- package/src/lib/action-ctx.ts +25 -0
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import type {goke} from 'goke'
|
|
2
2
|
|
|
3
|
+
import type {ActionCtx} from '../../lib/action-ctx'
|
|
4
|
+
|
|
3
5
|
import {z} from 'zod'
|
|
4
6
|
|
|
7
|
+
/** Minimal ctx surface consumed by cliproxy keys actions. Satisfied by both GokeExecutionContext and CapturedCtx. */
|
|
8
|
+
// ActionCtx imported from lib/action-ctx — single source of truth for action ctx shape
|
|
9
|
+
|
|
5
10
|
const DEFAULT_CLIPROXY_URL = 'https://cliproxy.fro.bot'
|
|
6
11
|
const HTTP_TIMEOUT_MS = 10_000
|
|
7
12
|
|
|
@@ -64,6 +69,114 @@ export function toStringArray(payload: unknown): string[] {
|
|
|
64
69
|
return []
|
|
65
70
|
}
|
|
66
71
|
|
|
72
|
+
export interface KeysListOptions {
|
|
73
|
+
url?: string
|
|
74
|
+
key?: string
|
|
75
|
+
json?: boolean
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function cliproxyKeysListAction(options: KeysListOptions, ctx: ActionCtx): Promise<string[]> {
|
|
79
|
+
try {
|
|
80
|
+
const baseUrl = resolveBaseUrl(options.url)
|
|
81
|
+
const managementKey = resolveManagementKey(options.key)
|
|
82
|
+
const endpoint = `${baseUrl}/v0/management/api-keys`
|
|
83
|
+
const payload = await requestJson(endpoint, {
|
|
84
|
+
method: 'GET',
|
|
85
|
+
headers: managementHeaders(managementKey),
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const keys = toStringArray(payload)
|
|
89
|
+
ctx.console.error('⚠️ Output contains API keys — avoid logging or storing in shared locations')
|
|
90
|
+
|
|
91
|
+
if (options.json) {
|
|
92
|
+
ctx.console.log(JSON.stringify(keys, null, 2))
|
|
93
|
+
} else if (keys.length === 0) {
|
|
94
|
+
ctx.console.log('No API keys configured')
|
|
95
|
+
} else {
|
|
96
|
+
for (const [index, apiKey] of keys.entries()) {
|
|
97
|
+
ctx.console.log(`${index + 1}. ${apiKey}`)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return keys
|
|
102
|
+
} catch (error) {
|
|
103
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
104
|
+
ctx.console.error(message)
|
|
105
|
+
ctx.process.exit(1)
|
|
106
|
+
return [] // unreachable; satisfies TS that all paths return
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface KeysAddOptions {
|
|
111
|
+
url?: string
|
|
112
|
+
key?: string
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function cliproxyKeysAddAction(
|
|
116
|
+
apiKeyToAdd: string,
|
|
117
|
+
options: KeysAddOptions,
|
|
118
|
+
ctx: ActionCtx,
|
|
119
|
+
): Promise<void> {
|
|
120
|
+
try {
|
|
121
|
+
const baseUrl = resolveBaseUrl(options.url)
|
|
122
|
+
const managementKey = resolveManagementKey(options.key)
|
|
123
|
+
const endpoint = `${baseUrl}/v0/management/api-keys`
|
|
124
|
+
|
|
125
|
+
const currentPayload = await requestJson(endpoint, {
|
|
126
|
+
method: 'GET',
|
|
127
|
+
headers: managementHeaders(managementKey),
|
|
128
|
+
})
|
|
129
|
+
const currentKeys = toStringArray(currentPayload)
|
|
130
|
+
|
|
131
|
+
if (currentKeys.includes(apiKeyToAdd)) {
|
|
132
|
+
ctx.console.log('Key already present; no update required.')
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const nextKeys = [...currentKeys, apiKeyToAdd]
|
|
137
|
+
await requestJson(endpoint, {
|
|
138
|
+
method: 'PUT',
|
|
139
|
+
headers: managementHeaders(managementKey),
|
|
140
|
+
body: JSON.stringify(nextKeys),
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
ctx.console.log(`Added key "${apiKeyToAdd}". Current key count: ${nextKeys.length}.`)
|
|
144
|
+
} catch (error) {
|
|
145
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
146
|
+
ctx.console.error(message)
|
|
147
|
+
ctx.process.exit(1)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface KeysRemoveOptions {
|
|
152
|
+
url?: string
|
|
153
|
+
key?: string
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function cliproxyKeysRemoveAction(
|
|
157
|
+
apiKeyToRemove: string,
|
|
158
|
+
options: KeysRemoveOptions,
|
|
159
|
+
ctx: ActionCtx,
|
|
160
|
+
): Promise<void> {
|
|
161
|
+
try {
|
|
162
|
+
const baseUrl = resolveBaseUrl(options.url)
|
|
163
|
+
const managementKey = resolveManagementKey(options.key)
|
|
164
|
+
const params = new URLSearchParams({value: apiKeyToRemove})
|
|
165
|
+
const endpoint = `${baseUrl}/v0/management/api-keys?${params.toString()}`
|
|
166
|
+
|
|
167
|
+
const payload = await requestJson(endpoint, {
|
|
168
|
+
method: 'DELETE',
|
|
169
|
+
headers: managementHeaders(managementKey),
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
ctx.console.log(JSON.stringify(payload, null, 2))
|
|
173
|
+
} catch (error) {
|
|
174
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
175
|
+
ctx.console.error(message)
|
|
176
|
+
ctx.process.exit(1)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
67
180
|
export function registerCliproxyKeys(cli: ReturnType<typeof goke>): void {
|
|
68
181
|
cli
|
|
69
182
|
.command('cliproxy keys list', 'List CLIProxyAPI API keys from the management API.')
|
|
@@ -80,28 +193,7 @@ export function registerCliproxyKeys(cli: ReturnType<typeof goke>): void {
|
|
|
80
193
|
z.string().describe('Management API key. Falls back to CLIPROXY_MANAGEMENT_KEY when omitted.'),
|
|
81
194
|
)
|
|
82
195
|
.option('--json', 'Output raw JSON array instead of a numbered list.')
|
|
83
|
-
.action(
|
|
84
|
-
const baseUrl = resolveBaseUrl(options.url)
|
|
85
|
-
const managementKey = resolveManagementKey(options.key)
|
|
86
|
-
const endpoint = `${baseUrl}/v0/management/api-keys`
|
|
87
|
-
const payload = await requestJson(endpoint, {
|
|
88
|
-
method: 'GET',
|
|
89
|
-
headers: managementHeaders(managementKey),
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
const keys = toStringArray(payload)
|
|
93
|
-
console.error('⚠️ Output contains API keys — avoid logging or storing in shared locations')
|
|
94
|
-
|
|
95
|
-
if (options.json) {
|
|
96
|
-
console.log(JSON.stringify(keys, null, 2))
|
|
97
|
-
} else if (keys.length === 0) {
|
|
98
|
-
console.log('No API keys configured')
|
|
99
|
-
} else {
|
|
100
|
-
for (const [index, key] of keys.entries()) {
|
|
101
|
-
console.log(`${index + 1}. ${key}`)
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
})
|
|
196
|
+
.action(cliproxyKeysListAction)
|
|
105
197
|
|
|
106
198
|
cli
|
|
107
199
|
.command(
|
|
@@ -122,31 +214,7 @@ export function registerCliproxyKeys(cli: ReturnType<typeof goke>): void {
|
|
|
122
214
|
)
|
|
123
215
|
.example('# Add a new API key to the current key set')
|
|
124
216
|
.example('infra cliproxy keys add sk-live-123')
|
|
125
|
-
.action(
|
|
126
|
-
const baseUrl = resolveBaseUrl(options.url)
|
|
127
|
-
const managementKey = resolveManagementKey(options.key)
|
|
128
|
-
const endpoint = `${baseUrl}/v0/management/api-keys`
|
|
129
|
-
|
|
130
|
-
const currentPayload = await requestJson(endpoint, {
|
|
131
|
-
method: 'GET',
|
|
132
|
-
headers: managementHeaders(managementKey),
|
|
133
|
-
})
|
|
134
|
-
const currentKeys = toStringArray(currentPayload)
|
|
135
|
-
|
|
136
|
-
if (currentKeys.includes(apiKeyToAdd)) {
|
|
137
|
-
console.log('Key already present; no update required.')
|
|
138
|
-
return
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const nextKeys = [...currentKeys, apiKeyToAdd]
|
|
142
|
-
await requestJson(endpoint, {
|
|
143
|
-
method: 'PUT',
|
|
144
|
-
headers: managementHeaders(managementKey),
|
|
145
|
-
body: JSON.stringify(nextKeys),
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
console.log(`Added key "${apiKeyToAdd}". Current key count: ${nextKeys.length}.`)
|
|
149
|
-
})
|
|
217
|
+
.action(cliproxyKeysAddAction)
|
|
150
218
|
|
|
151
219
|
cli
|
|
152
220
|
.command('cliproxy keys remove <key>', 'Remove an API key via management API endpoint query parameter.')
|
|
@@ -164,17 +232,5 @@ export function registerCliproxyKeys(cli: ReturnType<typeof goke>): void {
|
|
|
164
232
|
)
|
|
165
233
|
.example('# Remove an API key from CLIProxyAPI')
|
|
166
234
|
.example('infra cliproxy keys remove sk-live-123')
|
|
167
|
-
.action(
|
|
168
|
-
const baseUrl = resolveBaseUrl(options.url)
|
|
169
|
-
const managementKey = resolveManagementKey(options.key)
|
|
170
|
-
const params = new URLSearchParams({value: apiKeyToRemove})
|
|
171
|
-
const endpoint = `${baseUrl}/v0/management/api-keys?${params.toString()}`
|
|
172
|
-
|
|
173
|
-
const payload = await requestJson(endpoint, {
|
|
174
|
-
method: 'DELETE',
|
|
175
|
-
headers: managementHeaders(managementKey),
|
|
176
|
-
})
|
|
177
|
-
|
|
178
|
-
console.log(JSON.stringify(payload, null, 2))
|
|
179
|
-
})
|
|
235
|
+
.action(cliproxyKeysRemoveAction)
|
|
180
236
|
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
|
|
1
|
+
import type {SpawnFn} from './login'
|
|
3
2
|
|
|
4
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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 {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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 {
|
|
81
|
-
|
|
82
|
-
|
|
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 {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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 {
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
42
|
-
|
|
43
|
-
|
|
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
|
}
|