@marcusrbrown/infra 0.9.5 → 0.9.7
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
|
@@ -276,6 +276,32 @@ describe('cliproxy login', () => {
|
|
|
276
276
|
)
|
|
277
277
|
expect(calls).toHaveLength(1)
|
|
278
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
|
+
})
|
|
279
305
|
})
|
|
280
306
|
|
|
281
307
|
describe('ssh spawn contract', () => {
|
|
@@ -97,6 +97,14 @@ export async function cliproxyLoginAction(
|
|
|
97
97
|
|
|
98
98
|
const exitCode = await child.exited
|
|
99
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
|
+
|
|
100
108
|
throw new Error(`Remote login command failed with exit code ${exitCode}`)
|
|
101
109
|
}
|
|
102
110
|
|
package/src/commands/mcp.test.ts
CHANGED
|
@@ -73,6 +73,14 @@ function buildTestCli(): ReturnType<typeof goke> {
|
|
|
73
73
|
return cli
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/** Extract all text block content from a CallToolResult, joined by newline. */
|
|
77
|
+
function allText(result: CallToolResult): string {
|
|
78
|
+
return result.content
|
|
79
|
+
.filter((block): block is {type: 'text'; text: string} => block.type === 'text')
|
|
80
|
+
.map(block => block.text)
|
|
81
|
+
.join('\n')
|
|
82
|
+
}
|
|
83
|
+
|
|
76
84
|
// ─── Suite ────────────────────────────────────────────────────────────────────
|
|
77
85
|
|
|
78
86
|
describe('mcp integration (Tier-1, in-process)', () => {
|
|
@@ -125,24 +133,46 @@ describe('mcp integration (Tier-1, in-process)', () => {
|
|
|
125
133
|
|
|
126
134
|
// ── Mode B contract: gateway_backup ───────────────────────────────────────
|
|
127
135
|
//
|
|
128
|
-
// gateway_backup
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
136
|
+
// gateway_backup reads GATEWAY_HOST before attempting any SSH call.
|
|
137
|
+
// When GATEWAY_HOST is unset the action exits synchronously with a
|
|
138
|
+
// well-known error message — no network contact is made.
|
|
139
|
+
// The MCP layer must wrap that failure as an error CallToolResult
|
|
140
|
+
// (isError: true) rather than throwing, and the content must contain
|
|
141
|
+
// the exact error message from gatewayBackupAction so we catch contract
|
|
142
|
+
// regressions if the message ever changes.
|
|
143
|
+
|
|
144
|
+
test('gateway_backup wraps missing-GATEWAY_HOST as an error CallToolResult with the known message', async () => {
|
|
145
|
+
const originalHost = process.env.GATEWAY_HOST
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
// Delete GATEWAY_HOST to trigger the fast synchronous error path inside
|
|
149
|
+
// gatewayBackupAction — no SSH or network call is made.
|
|
150
|
+
delete process.env.GATEWAY_HOST
|
|
151
|
+
|
|
152
|
+
const rawResult = await client.callTool(
|
|
153
|
+
{
|
|
154
|
+
name: 'gateway_backup',
|
|
155
|
+
arguments: {},
|
|
156
|
+
},
|
|
157
|
+
CallToolResultSchema,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
const result: CallToolResult = CallToolResultSchema.parse(rawResult)
|
|
161
|
+
|
|
162
|
+
// The MCP layer must surface the error as a content block, not a thrown exception.
|
|
163
|
+
expect(result.isError).toBe(true)
|
|
164
|
+
expect(result.content.length).toBeGreaterThan(0)
|
|
165
|
+
|
|
166
|
+
// The content must carry the exact contract-bearing error string from
|
|
167
|
+
// gatewayBackupAction. This assertion fails if the error message changes.
|
|
168
|
+
expect(allText(result)).toContain('Gateway host not set. Export GATEWAY_HOST before running backup.')
|
|
169
|
+
} finally {
|
|
170
|
+
if (originalHost === undefined) {
|
|
171
|
+
delete process.env.GATEWAY_HOST
|
|
172
|
+
} else {
|
|
173
|
+
process.env.GATEWAY_HOST = originalHost
|
|
174
|
+
}
|
|
175
|
+
}
|
|
146
176
|
})
|
|
147
177
|
|
|
148
178
|
// ── Mode C contract: cliproxy_keys_list ───────────────────────────────────
|
|
@@ -177,13 +207,10 @@ describe('mcp integration (Tier-1, in-process)', () => {
|
|
|
177
207
|
// Mode C contract: at least 2 content blocks (stdout text + structured return)
|
|
178
208
|
expect(result.content.length).toBeGreaterThanOrEqual(2)
|
|
179
209
|
|
|
180
|
-
const
|
|
181
|
-
.filter((block): block is {type: 'text'; text: string} => block.type === 'text')
|
|
182
|
-
.map(block => block.text)
|
|
183
|
-
.join('\n')
|
|
210
|
+
const text = allText(result)
|
|
184
211
|
|
|
185
212
|
// One block must contain the formatted stdout (key names printed by the action)
|
|
186
|
-
expect(
|
|
213
|
+
expect(text).toContain('fro-bot-test1')
|
|
187
214
|
|
|
188
215
|
// One block must contain the stringified return value (JSON array of key names)
|
|
189
216
|
const hasStructuredReturn = result.content.some(block => {
|
|
@@ -205,4 +232,177 @@ describe('mcp integration (Tier-1, in-process)', () => {
|
|
|
205
232
|
}
|
|
206
233
|
}
|
|
207
234
|
})
|
|
235
|
+
|
|
236
|
+
// ── Positional-arg routing: cliproxy_keys_add ─────────────────────────────
|
|
237
|
+
//
|
|
238
|
+
// The MCP inputSchema for cliproxy_keys_add has a single required property
|
|
239
|
+
// `key` (derived from the command's positional `<key>` argument). This test
|
|
240
|
+
// proves that goke routes the MCP argument value through to the action's
|
|
241
|
+
// positional parameter (apiKeyToAdd) by asserting the key name appears in
|
|
242
|
+
// the action's output text.
|
|
243
|
+
//
|
|
244
|
+
// Verified inputSchema (empirical): { properties: { key: { type: "string",
|
|
245
|
+
// description: "Management API key…" }, url: { type: "string" } },
|
|
246
|
+
// required: ["key"] }
|
|
247
|
+
|
|
248
|
+
test('cliproxy_keys_add routes the positional key argument through MCP', async () => {
|
|
249
|
+
const originalFetch = globalThis.fetch
|
|
250
|
+
const originalKey = process.env.CLIPROXY_MANAGEMENT_KEY
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
process.env.CLIPROXY_MANAGEMENT_KEY = 'test-management-key'
|
|
254
|
+
|
|
255
|
+
globalThis.fetch = (async (_url: string | URL | Request, _init?: RequestInit): Promise<Response> => {
|
|
256
|
+
const method = typeof _init?.method === 'string' ? _init.method.toUpperCase() : 'GET'
|
|
257
|
+
if (method === 'GET') {
|
|
258
|
+
// Return empty current key list so the new key is not already present
|
|
259
|
+
return new Response(JSON.stringify([]), {
|
|
260
|
+
status: 200,
|
|
261
|
+
headers: {'content-type': 'application/json'},
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// PUT response — the action ignores the body and logs the new count
|
|
266
|
+
return new Response(JSON.stringify(['tier1-test-key']), {
|
|
267
|
+
status: 200,
|
|
268
|
+
headers: {'content-type': 'application/json'},
|
|
269
|
+
})
|
|
270
|
+
}) as typeof fetch
|
|
271
|
+
|
|
272
|
+
const rawResult = await client.callTool(
|
|
273
|
+
{
|
|
274
|
+
name: 'cliproxy_keys_add',
|
|
275
|
+
// `key` is the positional <key> arg (the API key to add).
|
|
276
|
+
// Verified from listTools() inputSchema — required property.
|
|
277
|
+
arguments: {key: 'tier1-test-key'},
|
|
278
|
+
},
|
|
279
|
+
CallToolResultSchema,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
const result: CallToolResult = CallToolResultSchema.parse(rawResult)
|
|
283
|
+
|
|
284
|
+
expect(result.isError).not.toBe(true)
|
|
285
|
+
// The action logs `Added key "tier1-test-key"` — presence proves the
|
|
286
|
+
// positional arg was correctly routed from the MCP call.
|
|
287
|
+
expect(allText(result)).toContain('tier1-test-key')
|
|
288
|
+
} finally {
|
|
289
|
+
globalThis.fetch = originalFetch
|
|
290
|
+
if (originalKey === undefined) {
|
|
291
|
+
delete process.env.CLIPROXY_MANAGEMENT_KEY
|
|
292
|
+
} else {
|
|
293
|
+
process.env.CLIPROXY_MANAGEMENT_KEY = originalKey
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
// ── Positional-arg routing: cliproxy_keys_remove ──────────────────────────
|
|
299
|
+
//
|
|
300
|
+
// The MCP inputSchema for cliproxy_keys_remove has a required property `key`
|
|
301
|
+
// (derived from the positional `<key>` argument). The action issues a DELETE
|
|
302
|
+
// request with `?value=<apiKeyToRemove>` and logs the response. The fetch mock
|
|
303
|
+
// echoes back the query-parameter value so we can assert it was routed correctly.
|
|
304
|
+
//
|
|
305
|
+
// Verified inputSchema (empirical): { properties: { key: { type: "string" },
|
|
306
|
+
// url: { type: "string" } }, required: ["key"] }
|
|
307
|
+
|
|
308
|
+
test('cliproxy_keys_remove routes the positional key argument through MCP', async () => {
|
|
309
|
+
const originalFetch = globalThis.fetch
|
|
310
|
+
const originalKey = process.env.CLIPROXY_MANAGEMENT_KEY
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
process.env.CLIPROXY_MANAGEMENT_KEY = 'test-management-key'
|
|
314
|
+
|
|
315
|
+
globalThis.fetch = (async (_url: string | URL | Request, _init?: RequestInit): Promise<Response> => {
|
|
316
|
+
// The action appends ?value=<apiKeyToRemove> — echo it back in the
|
|
317
|
+
// response body so the assertion can verify positional routing.
|
|
318
|
+
const urlStr = typeof _url === 'string' ? _url : _url instanceof URL ? _url.href : (_url as Request).url
|
|
319
|
+
const url = new URL(urlStr)
|
|
320
|
+
const removedKey = url.searchParams.get('value') ?? 'unknown'
|
|
321
|
+
return new Response(JSON.stringify({removed: removedKey}), {
|
|
322
|
+
status: 200,
|
|
323
|
+
headers: {'content-type': 'application/json'},
|
|
324
|
+
})
|
|
325
|
+
}) as typeof fetch
|
|
326
|
+
|
|
327
|
+
const rawResult = await client.callTool(
|
|
328
|
+
{
|
|
329
|
+
name: 'cliproxy_keys_remove',
|
|
330
|
+
// `key` is the positional <key> arg (the API key to remove).
|
|
331
|
+
// Verified from listTools() inputSchema — required property.
|
|
332
|
+
arguments: {key: 'tier1-test-remove-key'},
|
|
333
|
+
},
|
|
334
|
+
CallToolResultSchema,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
const result: CallToolResult = CallToolResultSchema.parse(rawResult)
|
|
338
|
+
|
|
339
|
+
expect(result.isError).not.toBe(true)
|
|
340
|
+
// The response includes the echoed ?value= param — proves the positional
|
|
341
|
+
// arg was routed to the DELETE URL query parameter.
|
|
342
|
+
expect(allText(result)).toContain('tier1-test-remove-key')
|
|
343
|
+
} finally {
|
|
344
|
+
globalThis.fetch = originalFetch
|
|
345
|
+
if (originalKey === undefined) {
|
|
346
|
+
delete process.env.CLIPROXY_MANAGEMENT_KEY
|
|
347
|
+
} else {
|
|
348
|
+
process.env.CLIPROXY_MANAGEMENT_KEY = originalKey
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
// ── Positional-arg routing: cliproxy_config_set ───────────────────────────
|
|
354
|
+
//
|
|
355
|
+
// The MCP inputSchema for cliproxy_config_set has two required properties:
|
|
356
|
+
// `field` and `value` (both positional). The action PUT-s `{value: <parsed>}`
|
|
357
|
+
// to the field-specific endpoint and logs the response JSON. Returning the
|
|
358
|
+
// value in the mock response lets us assert both positionals were routed.
|
|
359
|
+
//
|
|
360
|
+
// Verified inputSchema (empirical): { properties: { field: { type: "string",
|
|
361
|
+
// description: "Positional argument field" }, value: { type: "string",
|
|
362
|
+
// description: "Positional argument value" }, url: …, key: … },
|
|
363
|
+
// required: ["field","value"] }
|
|
364
|
+
|
|
365
|
+
test('cliproxy_config_set routes both positional arguments (field and value) through MCP', async () => {
|
|
366
|
+
const originalFetch = globalThis.fetch
|
|
367
|
+
const originalKey = process.env.CLIPROXY_MANAGEMENT_KEY
|
|
368
|
+
|
|
369
|
+
try {
|
|
370
|
+
process.env.CLIPROXY_MANAGEMENT_KEY = 'test-management-key'
|
|
371
|
+
|
|
372
|
+
globalThis.fetch = (async (_url: string | URL | Request, _init?: RequestInit): Promise<Response> => {
|
|
373
|
+
// Echo back the parsed body so we can assert the value was routed.
|
|
374
|
+
// The action sends {value: <parsedValue>}. We return it as-is.
|
|
375
|
+
const body = _init?.body
|
|
376
|
+
const parsed: unknown = body ? JSON.parse(String(body)) : {}
|
|
377
|
+
return new Response(JSON.stringify(parsed), {
|
|
378
|
+
status: 200,
|
|
379
|
+
headers: {'content-type': 'application/json'},
|
|
380
|
+
})
|
|
381
|
+
}) as typeof fetch
|
|
382
|
+
|
|
383
|
+
const rawResult = await client.callTool(
|
|
384
|
+
{
|
|
385
|
+
name: 'cliproxy_config_set',
|
|
386
|
+
// `field` and `value` are the positional args from `<field> <value>`.
|
|
387
|
+
// `proxy-url` is a string-type mutable field — no parsing needed.
|
|
388
|
+
arguments: {field: 'proxy-url', value: 'tier1-test-url'},
|
|
389
|
+
},
|
|
390
|
+
CallToolResultSchema,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
const result: CallToolResult = CallToolResultSchema.parse(rawResult)
|
|
394
|
+
|
|
395
|
+
expect(result.isError).not.toBe(true)
|
|
396
|
+
// The response echoes back {value: "tier1-test-url"} — presence of the
|
|
397
|
+
// value proves positional routing worked end-to-end.
|
|
398
|
+
expect(allText(result)).toContain('tier1-test-url')
|
|
399
|
+
} finally {
|
|
400
|
+
globalThis.fetch = originalFetch
|
|
401
|
+
if (originalKey === undefined) {
|
|
402
|
+
delete process.env.CLIPROXY_MANAGEMENT_KEY
|
|
403
|
+
} else {
|
|
404
|
+
process.env.CLIPROXY_MANAGEMENT_KEY = originalKey
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
})
|
|
208
408
|
})
|