@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.
@@ -1,8 +1,13 @@
1
1
  import type {goke} from 'goke'
2
2
 
3
+ import type {ActionCtx} from '../../lib/action-ctx'
3
4
  import {chmodSync} from 'node:fs'
5
+
4
6
  import {z} from 'zod'
5
7
 
8
+ /** Minimal ctx surface consumed by cliproxy config actions. Satisfied by both GokeExecutionContext and CapturedCtx. */
9
+ // ActionCtx imported from lib/action-ctx — single source of truth for action ctx shape
10
+
6
11
  const DEFAULT_CLIPROXY_URL = 'https://cliproxy.fro.bot'
7
12
  const HTTP_TIMEOUT_MS = 10_000
8
13
 
@@ -132,6 +137,83 @@ export function formatConfigAsColumns(payload: unknown): string {
132
137
  .join('\n')
133
138
  }
134
139
 
140
+ export interface ConfigGetOptions {
141
+ url?: string
142
+ key?: string
143
+ output?: string
144
+ json?: boolean
145
+ }
146
+
147
+ export async function cliproxyConfigGetAction(options: ConfigGetOptions, ctx: ActionCtx): Promise<unknown> {
148
+ try {
149
+ const baseUrl = resolveBaseUrl(options.url)
150
+ const managementKey = resolveManagementKey(options.key)
151
+ const endpoint = `${baseUrl}/v0/management/config`
152
+ const payload = await requestJson(endpoint, {
153
+ method: 'GET',
154
+ headers: managementHeaders(managementKey),
155
+ })
156
+
157
+ const jsonOutput = JSON.stringify(payload, null, 2)
158
+
159
+ if (options.output) {
160
+ try {
161
+ await Bun.write(options.output, jsonOutput)
162
+ chmodSync(options.output, 0o600)
163
+ ctx.console.log(`✓ Config written to ${options.output}`)
164
+ } catch (error: unknown) {
165
+ const message = error instanceof Error ? error.message : String(error)
166
+ ctx.console.error(`Failed to write config to ${options.output}: ${message}`)
167
+ ctx.process.exit(1)
168
+ return undefined // unreachable
169
+ }
170
+ } else if (options.json) {
171
+ ctx.console.error('⚠️ Output may contain API keys — avoid logging or storing in shared locations')
172
+ ctx.console.log(jsonOutput)
173
+ } else {
174
+ ctx.console.error('⚠️ Output may contain API keys — avoid logging or storing in shared locations')
175
+ ctx.console.log(formatConfigAsColumns(payload))
176
+ }
177
+
178
+ return payload
179
+ } catch (error) {
180
+ const message = error instanceof Error ? error.message : String(error)
181
+ ctx.console.error(message)
182
+ ctx.process.exit(1)
183
+ return undefined // unreachable; satisfies TS that all paths return
184
+ }
185
+ }
186
+
187
+ export interface ConfigSetOptions {
188
+ url?: string
189
+ key?: string
190
+ }
191
+
192
+ export async function cliproxyConfigSetAction(
193
+ field: string,
194
+ value: string,
195
+ options: ConfigSetOptions,
196
+ ctx: ActionCtx,
197
+ ): Promise<void> {
198
+ try {
199
+ const baseUrl = resolveBaseUrl(options.url)
200
+ const managementKey = resolveManagementKey(options.key)
201
+ const request = buildSetRequest(baseUrl, field, value)
202
+
203
+ const payload = await requestJson(request.endpoint, {
204
+ method: 'PUT',
205
+ headers: managementHeaders(managementKey),
206
+ body: request.body,
207
+ })
208
+
209
+ ctx.console.log(JSON.stringify(payload, null, 2))
210
+ } catch (error) {
211
+ const message = error instanceof Error ? error.message : String(error)
212
+ ctx.console.error(message)
213
+ ctx.process.exit(1)
214
+ }
215
+ }
216
+
135
217
  export function registerCliproxyConfig(cli: ReturnType<typeof goke>): void {
136
218
  cli
137
219
  .command('cliproxy config get', 'Fetch current CLIProxyAPI config as JSON.')
@@ -154,34 +236,7 @@ export function registerCliproxyConfig(cli: ReturnType<typeof goke>): void {
154
236
  .describe('Write config JSON to a file instead of stdout. File permissions set to 0600 (owner-read-only).'),
155
237
  )
156
238
  .option('--json', 'Output raw JSON instead of aligned key: value columns.')
157
- .action(async options => {
158
- const baseUrl = resolveBaseUrl(options.url)
159
- const managementKey = resolveManagementKey(options.key)
160
- const endpoint = `${baseUrl}/v0/management/config`
161
- const payload = await requestJson(endpoint, {
162
- method: 'GET',
163
- headers: managementHeaders(managementKey),
164
- })
165
-
166
- const jsonOutput = JSON.stringify(payload, null, 2)
167
-
168
- if (options.output) {
169
- try {
170
- await Bun.write(options.output, jsonOutput)
171
- chmodSync(options.output, 0o600)
172
- console.log(`✓ Config written to ${options.output}`)
173
- } catch (error: unknown) {
174
- const message = error instanceof Error ? error.message : String(error)
175
- throw new Error(`Failed to write config to ${options.output}: ${message}`)
176
- }
177
- } else if (options.json) {
178
- console.error('⚠️ Output may contain API keys — avoid logging or storing in shared locations')
179
- console.log(jsonOutput)
180
- } else {
181
- console.error('⚠️ Output may contain API keys — avoid logging or storing in shared locations')
182
- console.log(formatConfigAsColumns(payload))
183
- }
184
- })
239
+ .action(cliproxyConfigGetAction)
185
240
 
186
241
  cli
187
242
  .command(
@@ -203,17 +258,5 @@ export function registerCliproxyConfig(cli: ReturnType<typeof goke>): void {
203
258
  .example('infra cliproxy config set debug true')
204
259
  .example('infra cliproxy config set request-retry 5')
205
260
  .example('infra cliproxy config set proxy-url https://proxy.example.com')
206
- .action(async (field, value, options) => {
207
- const baseUrl = resolveBaseUrl(options.url)
208
- const managementKey = resolveManagementKey(options.key)
209
- const request = buildSetRequest(baseUrl, field, value)
210
-
211
- const payload = await requestJson(request.endpoint, {
212
- method: 'PUT',
213
- headers: managementHeaders(managementKey),
214
- body: request.body,
215
- })
216
-
217
- console.log(JSON.stringify(payload, null, 2))
218
- })
261
+ .action(cliproxyConfigSetAction)
219
262
  }
@@ -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
+ }
@@ -0,0 +1,326 @@
1
+ import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
2
+
3
+ import {createCapturedCtx, expectCapturedToInclude} from '../../__test__/mcp-ctx-fixture'
4
+ import {cliproxyKeysAddAction, cliproxyKeysListAction, cliproxyKeysRemoveAction, toStringArray} from './keys'
5
+
6
+ const originalFetch = globalThis.fetch
7
+
8
+ type FetchReplacement = (url: string, init?: RequestInit) => Promise<Response>
9
+
10
+ function createFetchImplementation(handler: FetchReplacement): typeof fetch {
11
+ return Object.assign(
12
+ (input: string | URL | Request, init?: RequestInit) => {
13
+ if (typeof input !== 'string') {
14
+ throw new TypeError(`Unexpected non-string fetch input: ${String(input)}`)
15
+ }
16
+
17
+ return handler(input, init)
18
+ },
19
+ {preconnect: originalFetch.preconnect},
20
+ )
21
+ }
22
+
23
+ describe('toStringArray', () => {
24
+ it('returns string array as-is', () => {
25
+ expect(toStringArray(['a', 'b', 'c'])).toEqual(['a', 'b', 'c'])
26
+ })
27
+
28
+ it('filters non-string items from array', () => {
29
+ expect(toStringArray(['a', 1, null, 'b'])).toEqual(['a', 'b'])
30
+ })
31
+
32
+ it('extracts api-keys from object', () => {
33
+ expect(toStringArray({'api-keys': ['x', 'y']})).toEqual(['x', 'y'])
34
+ })
35
+
36
+ it('extracts api_keys from object (underscore variant)', () => {
37
+ expect(toStringArray({api_keys: ['x', 'y']})).toEqual(['x', 'y'])
38
+ })
39
+
40
+ it('returns empty array for null', () => {
41
+ expect(toStringArray(null)).toEqual([])
42
+ })
43
+
44
+ it('returns empty array for unrecognized shape', () => {
45
+ expect(toStringArray({other: 'stuff'})).toEqual([])
46
+ })
47
+ })
48
+
49
+ describe('cliproxyKeysListAction (Mode C, Tier-2 ctx capture)', () => {
50
+ beforeEach(() => {
51
+ globalThis.fetch = createFetchImplementation(async () => {
52
+ throw new Error('Unexpected fetch call')
53
+ })
54
+ })
55
+
56
+ afterEach(() => {
57
+ globalThis.fetch = originalFetch
58
+ })
59
+
60
+ it('Mode C: captures numbered list to ctx.stdout and returns key array', async () => {
61
+ globalThis.fetch = createFetchImplementation(
62
+ async () =>
63
+ new Response(JSON.stringify(['sk-live-aaa', 'sk-live-bbb']), {
64
+ status: 200,
65
+ headers: {'content-type': 'application/json'},
66
+ }),
67
+ )
68
+
69
+ const {ctx, captured} = createCapturedCtx()
70
+ const result = await cliproxyKeysListAction({url: 'https://cliproxy.example.com', key: 'mgmt-key'}, ctx)
71
+
72
+ // Tier-2: stdout contains formatted list
73
+ expect(expectCapturedToInclude(captured, 'sk-live-aaa')).toBe(true)
74
+ expect(expectCapturedToInclude(captured, '1.')).toBe(true)
75
+ expect(expectCapturedToInclude(captured, '2.')).toBe(true)
76
+
77
+ // Mode C: action returns the parsed array
78
+ expect(Array.isArray(result)).toBe(true)
79
+ expect(result).toEqual(['sk-live-aaa', 'sk-live-bbb'])
80
+ })
81
+
82
+ it('Mode C: security warning goes to ctx.stderr', async () => {
83
+ globalThis.fetch = createFetchImplementation(
84
+ async () =>
85
+ new Response(JSON.stringify(['sk-live-aaa']), {
86
+ status: 200,
87
+ headers: {'content-type': 'application/json'},
88
+ }),
89
+ )
90
+
91
+ const {ctx, captured} = createCapturedCtx()
92
+ await cliproxyKeysListAction({url: 'https://cliproxy.example.com', key: 'mgmt-key'}, ctx)
93
+
94
+ const stderrText = captured.stderr.join('')
95
+ expect(stderrText).toContain('API keys')
96
+ })
97
+
98
+ it('Mode C: returns empty array and prints "No API keys configured" when list is empty', async () => {
99
+ globalThis.fetch = createFetchImplementation(
100
+ async () =>
101
+ new Response(JSON.stringify([]), {
102
+ status: 200,
103
+ headers: {'content-type': 'application/json'},
104
+ }),
105
+ )
106
+
107
+ const {ctx, captured} = createCapturedCtx()
108
+ const result = await cliproxyKeysListAction({url: 'https://cliproxy.example.com', key: 'mgmt-key'}, ctx)
109
+
110
+ expect(expectCapturedToInclude(captured, 'No API keys configured')).toBe(true)
111
+ expect(result).toEqual([])
112
+ })
113
+
114
+ it('Mode C: --json flag outputs JSON array to ctx.stdout', async () => {
115
+ globalThis.fetch = createFetchImplementation(
116
+ async () =>
117
+ new Response(JSON.stringify(['sk-live-aaa']), {
118
+ status: 200,
119
+ headers: {'content-type': 'application/json'},
120
+ }),
121
+ )
122
+
123
+ const {ctx, captured} = createCapturedCtx()
124
+ const result = await cliproxyKeysListAction({url: 'https://cliproxy.example.com', key: 'mgmt-key', json: true}, ctx)
125
+
126
+ const stdoutText = captured.stdout.join('')
127
+ const parsed = JSON.parse(stdoutText)
128
+ expect(parsed).toEqual(['sk-live-aaa'])
129
+ expect(result).toEqual(['sk-live-aaa'])
130
+ })
131
+ })
132
+
133
+ describe('cliproxyKeysAddAction (Mode A, positional arg, Tier-2 ctx capture)', () => {
134
+ afterEach(() => {
135
+ globalThis.fetch = originalFetch
136
+ })
137
+
138
+ it('Mode A: captures success message to ctx.stdout after adding key', async () => {
139
+ let putCalled = false
140
+ globalThis.fetch = createFetchImplementation(async (_url, init) => {
141
+ if (init?.method === 'PUT') {
142
+ putCalled = true
143
+ return new Response(JSON.stringify(['sk-live-existing', 'sk-live-new']), {
144
+ status: 200,
145
+ headers: {'content-type': 'application/json'},
146
+ })
147
+ }
148
+
149
+ // GET current keys
150
+ return new Response(JSON.stringify(['sk-live-existing']), {
151
+ status: 200,
152
+ headers: {'content-type': 'application/json'},
153
+ })
154
+ })
155
+
156
+ const {ctx, captured} = createCapturedCtx()
157
+ await cliproxyKeysAddAction('sk-live-new', {url: 'https://cliproxy.example.com', key: 'mgmt-key'}, ctx)
158
+
159
+ expect(putCalled).toBe(true)
160
+ expect(expectCapturedToInclude(captured, 'sk-live-new')).toBe(true)
161
+ expect(expectCapturedToInclude(captured, 'Added key')).toBe(true)
162
+ })
163
+
164
+ it('Mode A: skips PUT when key already present', async () => {
165
+ let putCalled = false
166
+ globalThis.fetch = createFetchImplementation(async (_url, init) => {
167
+ if (init?.method === 'PUT') {
168
+ putCalled = true
169
+ return new Response('{}', {status: 200})
170
+ }
171
+
172
+ return new Response(JSON.stringify(['sk-live-existing']), {
173
+ status: 200,
174
+ headers: {'content-type': 'application/json'},
175
+ })
176
+ })
177
+
178
+ const {ctx, captured} = createCapturedCtx()
179
+ await cliproxyKeysAddAction('sk-live-existing', {url: 'https://cliproxy.example.com', key: 'mgmt-key'}, ctx)
180
+
181
+ expect(putCalled).toBe(false)
182
+ expect(expectCapturedToInclude(captured, 'already present')).toBe(true)
183
+ })
184
+ })
185
+
186
+ describe('cliproxyKeysRemoveAction (Mode A, positional arg, Tier-2 ctx capture)', () => {
187
+ afterEach(() => {
188
+ globalThis.fetch = originalFetch
189
+ })
190
+
191
+ it('Mode A: captures DELETE response to ctx.stdout', async () => {
192
+ globalThis.fetch = createFetchImplementation(
193
+ async () =>
194
+ new Response(JSON.stringify({removed: true}), {
195
+ status: 200,
196
+ headers: {'content-type': 'application/json'},
197
+ }),
198
+ )
199
+
200
+ const {ctx, captured} = createCapturedCtx()
201
+ await cliproxyKeysRemoveAction('sk-live-old', {url: 'https://cliproxy.example.com', key: 'mgmt-key'}, ctx)
202
+
203
+ expect(expectCapturedToInclude(captured, 'removed')).toBe(true)
204
+ })
205
+ })
206
+
207
+ describe('cliproxyKeysListAction (Tier-2 failure-path parity)', () => {
208
+ const originalManagementKey = process.env.CLIPROXY_MANAGEMENT_KEY
209
+
210
+ afterEach(() => {
211
+ globalThis.fetch = originalFetch
212
+ if (originalManagementKey === undefined) {
213
+ delete process.env.CLIPROXY_MANAGEMENT_KEY
214
+ } else {
215
+ process.env.CLIPROXY_MANAGEMENT_KEY = originalManagementKey
216
+ }
217
+ })
218
+
219
+ it('Tier-2: missing CLIPROXY_MANAGEMENT_KEY routes to ctx.console.error + exit(1)', async () => {
220
+ delete process.env.CLIPROXY_MANAGEMENT_KEY
221
+
222
+ const {ctx, captured} = createCapturedCtx()
223
+ await expect(cliproxyKeysListAction({url: 'https://cliproxy.example.com'}, ctx)).rejects.toMatchObject({
224
+ name: 'MockProcessExit',
225
+ code: 1,
226
+ })
227
+ expect(captured.stderr.join('')).toContain('Management API key')
228
+ expect(captured.exit).toEqual({code: 1})
229
+ })
230
+
231
+ it('Tier-2: HTTP 500 response routes to ctx.console.error + exit(1)', async () => {
232
+ globalThis.fetch = createFetchImplementation(async () => new Response('server error', {status: 500}))
233
+
234
+ const {ctx, captured} = createCapturedCtx()
235
+ await expect(
236
+ cliproxyKeysListAction({url: 'https://cliproxy.example.com', key: 'mgmt-key'}, ctx),
237
+ ).rejects.toMatchObject({
238
+ name: 'MockProcessExit',
239
+ code: 1,
240
+ })
241
+ expect(captured.stderr.join('')).toContain('HTTP 500')
242
+ expect(captured.exit).toEqual({code: 1})
243
+ })
244
+ })
245
+
246
+ describe('cliproxyKeysAddAction (Tier-2 failure-path parity)', () => {
247
+ const originalManagementKey = process.env.CLIPROXY_MANAGEMENT_KEY
248
+
249
+ afterEach(() => {
250
+ globalThis.fetch = originalFetch
251
+ if (originalManagementKey === undefined) {
252
+ delete process.env.CLIPROXY_MANAGEMENT_KEY
253
+ } else {
254
+ process.env.CLIPROXY_MANAGEMENT_KEY = originalManagementKey
255
+ }
256
+ })
257
+
258
+ it('Tier-2: missing CLIPROXY_MANAGEMENT_KEY routes to ctx.console.error + exit(1)', async () => {
259
+ delete process.env.CLIPROXY_MANAGEMENT_KEY
260
+
261
+ const {ctx, captured} = createCapturedCtx()
262
+ await expect(
263
+ cliproxyKeysAddAction('sk-live-new', {url: 'https://cliproxy.example.com'}, ctx),
264
+ ).rejects.toMatchObject({
265
+ name: 'MockProcessExit',
266
+ code: 1,
267
+ })
268
+ expect(captured.stderr.join('')).toContain('Management API key')
269
+ expect(captured.exit).toEqual({code: 1})
270
+ })
271
+
272
+ it('Tier-2: HTTP 500 on GET routes to ctx.console.error + exit(1)', async () => {
273
+ globalThis.fetch = createFetchImplementation(async () => new Response('server error', {status: 500}))
274
+
275
+ const {ctx, captured} = createCapturedCtx()
276
+ await expect(
277
+ cliproxyKeysAddAction('sk-live-new', {url: 'https://cliproxy.example.com', key: 'mgmt-key'}, ctx),
278
+ ).rejects.toMatchObject({
279
+ name: 'MockProcessExit',
280
+ code: 1,
281
+ })
282
+ expect(captured.stderr.join('')).toContain('HTTP 500')
283
+ expect(captured.exit).toEqual({code: 1})
284
+ })
285
+ })
286
+
287
+ describe('cliproxyKeysRemoveAction (Tier-2 failure-path parity)', () => {
288
+ const originalManagementKey = process.env.CLIPROXY_MANAGEMENT_KEY
289
+
290
+ afterEach(() => {
291
+ globalThis.fetch = originalFetch
292
+ if (originalManagementKey === undefined) {
293
+ delete process.env.CLIPROXY_MANAGEMENT_KEY
294
+ } else {
295
+ process.env.CLIPROXY_MANAGEMENT_KEY = originalManagementKey
296
+ }
297
+ })
298
+
299
+ it('Tier-2: missing CLIPROXY_MANAGEMENT_KEY routes to ctx.console.error + exit(1)', async () => {
300
+ delete process.env.CLIPROXY_MANAGEMENT_KEY
301
+
302
+ const {ctx, captured} = createCapturedCtx()
303
+ await expect(
304
+ cliproxyKeysRemoveAction('sk-live-old', {url: 'https://cliproxy.example.com'}, ctx),
305
+ ).rejects.toMatchObject({
306
+ name: 'MockProcessExit',
307
+ code: 1,
308
+ })
309
+ expect(captured.stderr.join('')).toContain('Management API key')
310
+ expect(captured.exit).toEqual({code: 1})
311
+ })
312
+
313
+ it('Tier-2: HTTP 500 response routes to ctx.console.error + exit(1)', async () => {
314
+ globalThis.fetch = createFetchImplementation(async () => new Response('server error', {status: 500}))
315
+
316
+ const {ctx, captured} = createCapturedCtx()
317
+ await expect(
318
+ cliproxyKeysRemoveAction('sk-live-old', {url: 'https://cliproxy.example.com', key: 'mgmt-key'}, ctx),
319
+ ).rejects.toMatchObject({
320
+ name: 'MockProcessExit',
321
+ code: 1,
322
+ })
323
+ expect(captured.stderr.join('')).toContain('HTTP 500')
324
+ expect(captured.exit).toEqual({code: 1})
325
+ })
326
+ })