@marcusrbrown/infra 0.9.4 → 0.9.6
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 +1 -1
- package/src/commands/cliproxy/login.test.ts +101 -0
- package/src/commands/cliproxy/setup/preview.test.ts +1 -1
- package/src/commands/cliproxy/setup/templates.test.ts +12 -13
- package/src/commands/cliproxy/setup/templates.ts +0 -13
- package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +27 -5
- package/src/commands/cliproxy/setup/workflow-analyzer.ts +1 -2
- package/src/commands/cliproxy/setup.test.ts +9 -10
- package/src/commands/cliproxy/setup.ts +1 -1
- package/src/commands/mcp.test.ts +223 -23
package/package.json
CHANGED
|
@@ -239,6 +239,107 @@ describe('cliproxy login', () => {
|
|
|
239
239
|
})
|
|
240
240
|
})
|
|
241
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
|
+
|
|
281
|
+
describe('ssh spawn contract', () => {
|
|
282
|
+
it('happy path — claude: spawn argv and options match ssh boundary contract', async () => {
|
|
283
|
+
const {cliproxyLoginAction} = await import('./login')
|
|
284
|
+
const {spawnFn, calls} = makeSpawnOk(0)
|
|
285
|
+
setValidEnv()
|
|
286
|
+
process.env.SSH_AUTH_SOCK = '/tmp/contract-test.sock'
|
|
287
|
+
const capturedPath = process.env.PATH!
|
|
288
|
+
const capturedHome = process.env.HOME!
|
|
289
|
+
|
|
290
|
+
await cliproxyLoginAction('claude', {}, spawnFn)
|
|
291
|
+
|
|
292
|
+
expect(calls).toHaveLength(1)
|
|
293
|
+
const {cmd, opts} = calls[0]!
|
|
294
|
+
const spawnOpts = opts as Parameters<SpawnFn>[1]
|
|
295
|
+
|
|
296
|
+
// SSH flags
|
|
297
|
+
expect(cmd).toContain('-tt')
|
|
298
|
+
expect(cmd).toContain('BatchMode=yes')
|
|
299
|
+
expect(cmd).toContain('ConnectTimeout=10')
|
|
300
|
+
|
|
301
|
+
// stdio inheritance
|
|
302
|
+
expect(spawnOpts.stdin).toBe('inherit')
|
|
303
|
+
expect(spawnOpts.stdout).toBe('inherit')
|
|
304
|
+
expect(spawnOpts.stderr).toBe('inherit')
|
|
305
|
+
|
|
306
|
+
// forwarded env vars
|
|
307
|
+
expect(spawnOpts.env.PATH).toBe(capturedPath)
|
|
308
|
+
expect(spawnOpts.env.HOME).toBe(capturedHome)
|
|
309
|
+
expect(spawnOpts.env.SSH_AUTH_SOCK).toBe('/tmp/contract-test.sock')
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('happy path — codex: spawn argv and options match ssh boundary contract', async () => {
|
|
313
|
+
const {cliproxyLoginAction} = await import('./login')
|
|
314
|
+
const {spawnFn, calls} = makeSpawnOk(0)
|
|
315
|
+
setValidEnv()
|
|
316
|
+
process.env.SSH_AUTH_SOCK = '/tmp/contract-test.sock'
|
|
317
|
+
const capturedPath = process.env.PATH!
|
|
318
|
+
const capturedHome = process.env.HOME!
|
|
319
|
+
|
|
320
|
+
await cliproxyLoginAction('codex', {}, spawnFn)
|
|
321
|
+
|
|
322
|
+
expect(calls).toHaveLength(1)
|
|
323
|
+
const {cmd, opts} = calls[0]!
|
|
324
|
+
const spawnOpts = opts as Parameters<SpawnFn>[1]
|
|
325
|
+
|
|
326
|
+
// SSH flags
|
|
327
|
+
expect(cmd).toContain('-tt')
|
|
328
|
+
expect(cmd).toContain('BatchMode=yes')
|
|
329
|
+
expect(cmd).toContain('ConnectTimeout=10')
|
|
330
|
+
|
|
331
|
+
// stdio inheritance
|
|
332
|
+
expect(spawnOpts.stdin).toBe('inherit')
|
|
333
|
+
expect(spawnOpts.stdout).toBe('inherit')
|
|
334
|
+
expect(spawnOpts.stderr).toBe('inherit')
|
|
335
|
+
|
|
336
|
+
// forwarded env vars
|
|
337
|
+
expect(spawnOpts.env.PATH).toBe(capturedPath)
|
|
338
|
+
expect(spawnOpts.env.HOME).toBe(capturedHome)
|
|
339
|
+
expect(spawnOpts.env.SSH_AUTH_SOCK).toBe('/tmp/contract-test.sock')
|
|
340
|
+
})
|
|
341
|
+
})
|
|
342
|
+
|
|
242
343
|
describe('anti-phishing notice', () => {
|
|
243
344
|
let logLines: string[]
|
|
244
345
|
let originalLog: typeof console.log
|
|
@@ -37,7 +37,7 @@ describe('formatDryRunPreview', () => {
|
|
|
37
37
|
expect(preview).toContain('Planned secrets:')
|
|
38
38
|
expect(preview).toContain('OPENCODE_AUTH_JSON')
|
|
39
39
|
expect(preview).toContain('OPENCODE_CONFIG')
|
|
40
|
-
expect(preview).toContain('OMO_PROVIDERS')
|
|
40
|
+
expect(preview).not.toContain('OMO_PROVIDERS')
|
|
41
41
|
})
|
|
42
42
|
|
|
43
43
|
it('renders planned variables', () => {
|
|
@@ -123,7 +123,6 @@ describe('getHarnessTemplate', () => {
|
|
|
123
123
|
expect(template.secrets.map((entry: SecretAssignment) => entry.name)).toEqual([
|
|
124
124
|
'OPENCODE_AUTH_JSON',
|
|
125
125
|
'OPENCODE_CONFIG',
|
|
126
|
-
'OMO_PROVIDERS',
|
|
127
126
|
])
|
|
128
127
|
expect(template.variables.map((entry: VariableAssignment) => entry.name)).toEqual(['FRO_BOT_MODEL'])
|
|
129
128
|
})
|
|
@@ -135,11 +134,11 @@ describe('getHarnessTemplate', () => {
|
|
|
135
134
|
expect(modelEntry?.value).toMatch(/^anthropic\//)
|
|
136
135
|
})
|
|
137
136
|
|
|
138
|
-
it('
|
|
137
|
+
it('does not include an OMO_PROVIDERS secret', () => {
|
|
139
138
|
const template = getHarnessTemplate('opencode', {keyValue: 'sk-test'})
|
|
140
|
-
const
|
|
139
|
+
const secretNames = template.secrets.map((entry: SecretAssignment) => entry.name)
|
|
141
140
|
|
|
142
|
-
expect(
|
|
141
|
+
expect(secretNames).not.toContain('OMO_PROVIDERS')
|
|
143
142
|
})
|
|
144
143
|
|
|
145
144
|
it('writes an OPENCODE_CONFIG baseURL with the /v1 suffix', () => {
|
|
@@ -183,11 +182,11 @@ describe('getHarnessTemplate provider-aware', () => {
|
|
|
183
182
|
expect(configEntry?.value).toBe(ANTHROPIC_ONLY_CONFIG)
|
|
184
183
|
})
|
|
185
184
|
|
|
186
|
-
it('no providers/model args → OMO_PROVIDERS is
|
|
185
|
+
it('no providers/model args → OMO_PROVIDERS is not emitted', () => {
|
|
187
186
|
const template = getHarnessTemplate('opencode', {keyValue: 'test-key'})
|
|
188
|
-
const
|
|
187
|
+
const secretNames = template.secrets.map((e: SecretAssignment) => e.name)
|
|
189
188
|
|
|
190
|
-
expect(
|
|
189
|
+
expect(secretNames).not.toContain('OMO_PROVIDERS')
|
|
191
190
|
})
|
|
192
191
|
|
|
193
192
|
it('no providers/model args → FRO_BOT_MODEL is anthropic/claude-sonnet-4-6', () => {
|
|
@@ -234,15 +233,15 @@ describe('getHarnessTemplate provider-aware', () => {
|
|
|
234
233
|
expect(configEntry?.value).toBe('{"provider":{"openai":{"options":{"baseURL":"https://cliproxy.fro.bot/v1"}}}}')
|
|
235
234
|
})
|
|
236
235
|
|
|
237
|
-
it("providers: ['openai'], model: 'openai/gpt-5.4-mini' → OMO_PROVIDERS is
|
|
236
|
+
it("providers: ['openai'], model: 'openai/gpt-5.4-mini' → OMO_PROVIDERS is not emitted", () => {
|
|
238
237
|
const template = getHarnessTemplate('opencode', {
|
|
239
238
|
keyValue: 'sk-openai-key',
|
|
240
239
|
providers: ['openai'],
|
|
241
240
|
model: 'openai/gpt-5.4-mini',
|
|
242
241
|
})
|
|
243
|
-
const
|
|
242
|
+
const secretNames = template.secrets.map((e: SecretAssignment) => e.name)
|
|
244
243
|
|
|
245
|
-
expect(
|
|
244
|
+
expect(secretNames).not.toContain('OMO_PROVIDERS')
|
|
246
245
|
})
|
|
247
246
|
|
|
248
247
|
it("providers: ['openai'], model: 'openai/gpt-5.4-mini' → FRO_BOT_MODEL is openai/gpt-5.4-mini", () => {
|
|
@@ -294,15 +293,15 @@ describe('getHarnessTemplate provider-aware', () => {
|
|
|
294
293
|
)
|
|
295
294
|
})
|
|
296
295
|
|
|
297
|
-
it("providers: ['anthropic', 'openai'] → OMO_PROVIDERS is
|
|
296
|
+
it("providers: ['anthropic', 'openai'] → OMO_PROVIDERS is not emitted", () => {
|
|
298
297
|
const template = getHarnessTemplate('opencode', {
|
|
299
298
|
keyValue: 'sk-dual',
|
|
300
299
|
providers: ['anthropic', 'openai'],
|
|
301
300
|
model: 'openai/gpt-5.4-mini',
|
|
302
301
|
})
|
|
303
|
-
const
|
|
302
|
+
const secretNames = template.secrets.map((e: SecretAssignment) => e.name)
|
|
304
303
|
|
|
305
|
-
expect(
|
|
304
|
+
expect(secretNames).not.toContain('OMO_PROVIDERS')
|
|
306
305
|
})
|
|
307
306
|
|
|
308
307
|
it("providers: ['anthropic', 'openai'] → FRO_BOT_MODEL is the supplied model", () => {
|
|
@@ -25,13 +25,6 @@ export interface HarnessTemplate {
|
|
|
25
25
|
|
|
26
26
|
const DEFAULT_CLIPROXY_URL = 'https://cliproxy.fro.bot'
|
|
27
27
|
|
|
28
|
-
// OMO_PROVIDERS token map — template/harness concern, not a provider concern.
|
|
29
|
-
// Keyed by ProviderId but lives here because it drives harness template construction.
|
|
30
|
-
const OMO_TOKEN: Record<ProviderId, string> = {
|
|
31
|
-
anthropic: 'claude-max20',
|
|
32
|
-
openai: 'openai',
|
|
33
|
-
}
|
|
34
|
-
|
|
35
28
|
export function stripTrailingSlash(value: string): string {
|
|
36
29
|
return value.endsWith('/') ? value.slice(0, -1) : value
|
|
37
30
|
}
|
|
@@ -78,8 +71,6 @@ export function getHarnessTemplate(
|
|
|
78
71
|
providerConfig[p] = {options: {baseURL: `${baseUrl}/v1`}}
|
|
79
72
|
}
|
|
80
73
|
|
|
81
|
-
const omoProviders = providers.map(p => OMO_TOKEN[p]).join(',')
|
|
82
|
-
|
|
83
74
|
return {
|
|
84
75
|
secrets: [
|
|
85
76
|
{
|
|
@@ -90,10 +81,6 @@ export function getHarnessTemplate(
|
|
|
90
81
|
name: 'OPENCODE_CONFIG',
|
|
91
82
|
value: JSON.stringify({provider: providerConfig}),
|
|
92
83
|
},
|
|
93
|
-
{
|
|
94
|
-
name: 'OMO_PROVIDERS',
|
|
95
|
-
value: omoProviders,
|
|
96
|
-
},
|
|
97
84
|
],
|
|
98
85
|
variables: [
|
|
99
86
|
{
|
|
@@ -230,14 +230,36 @@ describe('cliproxy setup helpers', () => {
|
|
|
230
230
|
expect(Object.keys(dualResult)).toEqual(['kind', 'stepsWithGaps'])
|
|
231
231
|
})
|
|
232
232
|
|
|
233
|
-
it('REQUIRED_OPENCODE_INPUTS covers exactly auth-json, opencode-config, omo-providers,
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
const
|
|
233
|
+
it('REQUIRED_OPENCODE_INPUTS covers exactly auth-json, opencode-config, model (no omo-providers, no enable-omo)', () => {
|
|
234
|
+
// A workflow that wires auth-json, opencode-config, model (but NOT omo-providers) must
|
|
235
|
+
// pass with zero gaps — omo-providers is vestigial and must not be required.
|
|
236
|
+
const workflowWithoutOmo = ` - uses: fro-bot/agent@abc123
|
|
237
|
+
with:
|
|
238
|
+
github-token: \${{ secrets.FRO_BOT_PAT }}
|
|
239
|
+
auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
|
|
240
|
+
model: \${{ vars.FRO_BOT_MODEL }}
|
|
241
|
+
opencode-config: \${{ secrets.OPENCODE_CONFIG }}
|
|
242
|
+
prompt: \${{ env.PROMPT }}
|
|
243
|
+
`
|
|
244
|
+
const result = analyzeFroBotWorkflow(workflowWithoutOmo)
|
|
245
|
+
|
|
246
|
+
expect(result.kind).toBe('analyzed')
|
|
247
|
+
if (result.kind !== 'analyzed') throw new Error('unreachable')
|
|
248
|
+
// Zero gaps confirms omo-providers is NOT required.
|
|
249
|
+
expect(result.stepsWithGaps).toEqual([])
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('a workflow missing only omo-providers (but with auth-json, opencode-config, model) is not flagged', () => {
|
|
253
|
+
const workflowWithoutOmo = ` - uses: fro-bot/agent@abc123
|
|
254
|
+
with:
|
|
255
|
+
auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
|
|
256
|
+
opencode-config: \${{ secrets.OPENCODE_CONFIG }}
|
|
257
|
+
model: \${{ vars.FRO_BOT_MODEL }}
|
|
258
|
+
`
|
|
259
|
+
const result = analyzeFroBotWorkflow(workflowWithoutOmo)
|
|
237
260
|
|
|
238
261
|
expect(result.kind).toBe('analyzed')
|
|
239
262
|
if (result.kind !== 'analyzed') throw new Error('unreachable')
|
|
240
|
-
// Zero gaps confirms the four inputs in the fixture are sufficient — enable-omo is NOT required.
|
|
241
263
|
expect(result.stepsWithGaps).toEqual([])
|
|
242
264
|
})
|
|
243
265
|
})
|
|
@@ -23,7 +23,7 @@ export type FroBotWorkflowCheck =
|
|
|
23
23
|
// For proxy-routed providers configured via OPENCODE_CONFIG.provider.<name>.options.baseURL,
|
|
24
24
|
// the fro-bot/agent action honors auth.json directly (regardless of oMo state).
|
|
25
25
|
// Source: fro-bot/agent@v0.44.3+ action.yaml lines 99-104; verified by librarian 2026-05-25.
|
|
26
|
-
const REQUIRED_OPENCODE_INPUTS = ['auth-json', 'opencode-config', '
|
|
26
|
+
const REQUIRED_OPENCODE_INPUTS = ['auth-json', 'opencode-config', 'model'] as const
|
|
27
27
|
type RequiredOpencodeInput = (typeof REQUIRED_OPENCODE_INPUTS)[number]
|
|
28
28
|
|
|
29
29
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
@@ -129,7 +129,6 @@ export function formatWorkflowSnippet(missingInputs: readonly string[]): string
|
|
|
129
129
|
const inputMap = {
|
|
130
130
|
'auth-json': 'auth-json: ${{ secrets.OPENCODE_AUTH_JSON }}',
|
|
131
131
|
'opencode-config': 'opencode-config: ${{ secrets.OPENCODE_CONFIG }}',
|
|
132
|
-
'omo-providers': 'omo-providers: ${{ secrets.OMO_PROVIDERS }}',
|
|
133
132
|
model: 'model: ${{ vars.FRO_BOT_MODEL }}',
|
|
134
133
|
} satisfies Record<RequiredOpencodeInput, string>
|
|
135
134
|
/* eslint-enable no-template-curly-in-string */
|
|
@@ -2038,7 +2038,7 @@ describe('post-write readback verification', () => {
|
|
|
2038
2038
|
|
|
2039
2039
|
it('happy path: readback returns all written names → no new warning emitted', async () => {
|
|
2040
2040
|
// Pre-write list is empty; post-write readback returns all written names
|
|
2041
|
-
// The opencode harness writes: OPENCODE_AUTH_JSON, OPENCODE_CONFIG
|
|
2041
|
+
// The opencode harness writes: OPENCODE_AUTH_JSON, OPENCODE_CONFIG (secrets)
|
|
2042
2042
|
// and FRO_BOT_MODEL (variable)
|
|
2043
2043
|
let callCount = 0
|
|
2044
2044
|
const {deps} = makeDeps(async (_repo, kind) => {
|
|
@@ -2048,7 +2048,7 @@ describe('post-write readback verification', () => {
|
|
|
2048
2048
|
return []
|
|
2049
2049
|
}
|
|
2050
2050
|
// Post-write readback: return all written names
|
|
2051
|
-
if (kind === 'secret') return ['OPENCODE_AUTH_JSON', 'OPENCODE_CONFIG'
|
|
2051
|
+
if (kind === 'secret') return ['OPENCODE_AUTH_JSON', 'OPENCODE_CONFIG']
|
|
2052
2052
|
return ['FRO_BOT_MODEL']
|
|
2053
2053
|
})
|
|
2054
2054
|
|
|
@@ -2068,7 +2068,7 @@ describe('post-write readback verification', () => {
|
|
|
2068
2068
|
callCount++
|
|
2069
2069
|
if (callCount <= 2) return []
|
|
2070
2070
|
// Post-write: secret readback missing OPENCODE_AUTH_JSON
|
|
2071
|
-
if (kind === 'secret') return ['OPENCODE_CONFIG'
|
|
2071
|
+
if (kind === 'secret') return ['OPENCODE_CONFIG']
|
|
2072
2072
|
return ['FRO_BOT_MODEL']
|
|
2073
2073
|
})
|
|
2074
2074
|
|
|
@@ -2088,7 +2088,7 @@ describe('post-write readback verification', () => {
|
|
|
2088
2088
|
const {deps} = makeDeps(async (_repo, kind) => {
|
|
2089
2089
|
callCount++
|
|
2090
2090
|
if (callCount <= 2) return []
|
|
2091
|
-
if (kind === 'secret') return ['OPENCODE_AUTH_JSON', 'OPENCODE_CONFIG'
|
|
2091
|
+
if (kind === 'secret') return ['OPENCODE_AUTH_JSON', 'OPENCODE_CONFIG']
|
|
2092
2092
|
// Variable readback missing FRO_BOT_MODEL
|
|
2093
2093
|
return []
|
|
2094
2094
|
})
|
|
@@ -2102,12 +2102,12 @@ describe('post-write readback verification', () => {
|
|
|
2102
2102
|
})
|
|
2103
2103
|
|
|
2104
2104
|
it('partial visibility: readback shows some but not all written names → warning lists exactly the absent names', async () => {
|
|
2105
|
-
// Post-write: OPENCODE_CONFIG
|
|
2105
|
+
// Post-write: OPENCODE_CONFIG present, OPENCODE_AUTH_JSON absent
|
|
2106
2106
|
let callCount = 0
|
|
2107
2107
|
const {deps} = makeDeps(async (_repo, kind) => {
|
|
2108
2108
|
callCount++
|
|
2109
2109
|
if (callCount <= 2) return []
|
|
2110
|
-
if (kind === 'secret') return ['OPENCODE_CONFIG'
|
|
2110
|
+
if (kind === 'secret') return ['OPENCODE_CONFIG'] // OPENCODE_AUTH_JSON absent
|
|
2111
2111
|
return ['FRO_BOT_MODEL']
|
|
2112
2112
|
})
|
|
2113
2113
|
|
|
@@ -2117,9 +2117,8 @@ describe('post-write readback verification', () => {
|
|
|
2117
2117
|
expect(mismatchWarnings.length).toBeGreaterThan(0)
|
|
2118
2118
|
// Must name OPENCODE_AUTH_JSON (absent)
|
|
2119
2119
|
expect(mismatchWarnings.some(m => m.includes('OPENCODE_AUTH_JSON'))).toBe(true)
|
|
2120
|
-
// Must NOT name OPENCODE_CONFIG
|
|
2120
|
+
// Must NOT name OPENCODE_CONFIG (it IS present)
|
|
2121
2121
|
expect(mismatchWarnings.some(m => m.includes('OPENCODE_CONFIG'))).toBe(false)
|
|
2122
|
-
expect(mismatchWarnings.some(m => m.includes('OMO_PROVIDERS'))).toBe(false)
|
|
2123
2122
|
})
|
|
2124
2123
|
|
|
2125
2124
|
it('cannot verify: listExistingGhNames throws on post-write call → softer warning, command does NOT throw, rollback NOT fired', async () => {
|
|
@@ -2268,7 +2267,7 @@ describe('post-write readback verification', () => {
|
|
|
2268
2267
|
return []
|
|
2269
2268
|
}
|
|
2270
2269
|
// Post-write readback: all names present
|
|
2271
|
-
if (kind === 'secret') return ['OPENCODE_AUTH_JSON', 'OPENCODE_CONFIG'
|
|
2270
|
+
if (kind === 'secret') return ['OPENCODE_AUTH_JSON', 'OPENCODE_CONFIG']
|
|
2272
2271
|
return ['FRO_BOT_MODEL']
|
|
2273
2272
|
})
|
|
2274
2273
|
|
|
@@ -2338,7 +2337,7 @@ describe('non-interactive overwrite warning concurrency caveat', () => {
|
|
|
2338
2337
|
it('--force overwrite with a collision present → warning carries the last-write-wins concurrency caveat', async () => {
|
|
2339
2338
|
// OPENCODE_AUTH_JSON already exists → collision on the opencode secret set → overwrite warning fires.
|
|
2340
2339
|
const deps = makeDeps(async (_repo, kind) => {
|
|
2341
|
-
if (kind === 'secret') return ['OPENCODE_AUTH_JSON', 'OPENCODE_CONFIG'
|
|
2340
|
+
if (kind === 'secret') return ['OPENCODE_AUTH_JSON', 'OPENCODE_CONFIG']
|
|
2342
2341
|
return ['FRO_BOT_MODEL']
|
|
2343
2342
|
})
|
|
2344
2343
|
|
|
@@ -350,7 +350,7 @@ export async function buildNonInteractivePlan(
|
|
|
350
350
|
// Check BEFORE verifyModelsAvailable to avoid a network call when the gate will reject anyway.
|
|
351
351
|
if (requiresDestructiveProviderChangeConfirmation(providers) && !options.force) {
|
|
352
352
|
throw new Error(
|
|
353
|
-
`Refusing destructive provider change on ${options.repo ?? ''} without --force. Selected providers ${providers.join(', ')} would overwrite existing GitHub secret values (OPENCODE_AUTH_JSON, OPENCODE_CONFIG,
|
|
353
|
+
`Refusing destructive provider change on ${options.repo ?? ''} without --force. Selected providers ${providers.join(', ')} would overwrite existing GitHub secret values (OPENCODE_AUTH_JSON, OPENCODE_CONFIG, FRO_BOT_MODEL). Note: --force authorizes overwriting these GitHub secret values; it does NOT rotate the underlying CLIProxyAPI proxy bearer token (which is preserved byte-for-byte when --key is supplied).`,
|
|
354
354
|
)
|
|
355
355
|
}
|
|
356
356
|
|
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
|
})
|