@marcusrbrown/infra 0.13.0 → 0.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/dist/cli.js +4977 -0
  2. package/package.json +7 -3
  3. package/src/__snapshots__/cli.test.ts.snap +0 -219
  4. package/src/__test__/mcp-ctx-fixture.test.ts +0 -108
  5. package/src/__test__/mcp-ctx-fixture.ts +0 -104
  6. package/src/cli.test.ts +0 -66
  7. package/src/cli.ts +0 -43
  8. package/src/commands/cliproxy/config.test.ts +0 -475
  9. package/src/commands/cliproxy/config.ts +0 -238
  10. package/src/commands/cliproxy/deploy.test.ts +0 -181
  11. package/src/commands/cliproxy/deploy.ts +0 -157
  12. package/src/commands/cliproxy/host.test.ts +0 -73
  13. package/src/commands/cliproxy/host.ts +0 -31
  14. package/src/commands/cliproxy/index.ts +0 -21
  15. package/src/commands/cliproxy/keys.test.ts +0 -326
  16. package/src/commands/cliproxy/keys.ts +0 -201
  17. package/src/commands/cliproxy/login.test.ts +0 -415
  18. package/src/commands/cliproxy/login.ts +0 -131
  19. package/src/commands/cliproxy/models.test.ts +0 -592
  20. package/src/commands/cliproxy/models.ts +0 -237
  21. package/src/commands/cliproxy/open.test.ts +0 -132
  22. package/src/commands/cliproxy/open.ts +0 -78
  23. package/src/commands/cliproxy/setup/gh.test.ts +0 -218
  24. package/src/commands/cliproxy/setup/gh.ts +0 -250
  25. package/src/commands/cliproxy/setup/preview.test.ts +0 -159
  26. package/src/commands/cliproxy/setup/preview.ts +0 -41
  27. package/src/commands/cliproxy/setup/prompts.test.ts +0 -58
  28. package/src/commands/cliproxy/setup/prompts.ts +0 -99
  29. package/src/commands/cliproxy/setup/providers.test.ts +0 -245
  30. package/src/commands/cliproxy/setup/providers.ts +0 -136
  31. package/src/commands/cliproxy/setup/smoke-test.test.ts +0 -821
  32. package/src/commands/cliproxy/setup/smoke-test.ts +0 -223
  33. package/src/commands/cliproxy/setup/templates.test.ts +0 -357
  34. package/src/commands/cliproxy/setup/templates.ts +0 -145
  35. package/src/commands/cliproxy/setup/validation.test.ts +0 -567
  36. package/src/commands/cliproxy/setup/validation.ts +0 -209
  37. package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +0 -363
  38. package/src/commands/cliproxy/setup/workflow-analyzer.ts +0 -136
  39. package/src/commands/cliproxy/setup.test.ts +0 -2366
  40. package/src/commands/cliproxy/setup.ts +0 -809
  41. package/src/commands/cliproxy/shared.test.ts +0 -118
  42. package/src/commands/cliproxy/shared.ts +0 -84
  43. package/src/commands/cliproxy/status.test.ts +0 -1157
  44. package/src/commands/cliproxy/status.ts +0 -597
  45. package/src/commands/dashboard/deploy.test.ts +0 -215
  46. package/src/commands/dashboard/deploy.ts +0 -135
  47. package/src/commands/dashboard/host.test.ts +0 -79
  48. package/src/commands/dashboard/host.ts +0 -33
  49. package/src/commands/dashboard/index.ts +0 -13
  50. package/src/commands/dashboard/logs.test.ts +0 -317
  51. package/src/commands/dashboard/logs.ts +0 -180
  52. package/src/commands/dashboard/status.test.ts +0 -708
  53. package/src/commands/dashboard/status.ts +0 -293
  54. package/src/commands/gateway/backup.test.ts +0 -426
  55. package/src/commands/gateway/backup.ts +0 -205
  56. package/src/commands/gateway/deploy.test.ts +0 -491
  57. package/src/commands/gateway/deploy.ts +0 -173
  58. package/src/commands/gateway/host.test.ts +0 -89
  59. package/src/commands/gateway/host.ts +0 -33
  60. package/src/commands/gateway/index.ts +0 -17
  61. package/src/commands/gateway/logs.test.ts +0 -263
  62. package/src/commands/gateway/logs.ts +0 -160
  63. package/src/commands/gateway/restore.test.ts +0 -650
  64. package/src/commands/gateway/restore.ts +0 -308
  65. package/src/commands/gateway/status.test.ts +0 -755
  66. package/src/commands/gateway/status.ts +0 -255
  67. package/src/commands/keeweb/deploy.test.ts +0 -177
  68. package/src/commands/keeweb/deploy.ts +0 -191
  69. package/src/commands/keeweb/index.ts +0 -11
  70. package/src/commands/keeweb/open.test.ts +0 -54
  71. package/src/commands/keeweb/open.ts +0 -30
  72. package/src/commands/keeweb/status.test.ts +0 -509
  73. package/src/commands/keeweb/status.ts +0 -359
  74. package/src/commands/mcp.test.ts +0 -260
  75. package/src/commands/mcp.ts +0 -58
  76. package/src/commands/status.test.ts +0 -222
  77. package/src/commands/status.ts +0 -161
  78. package/src/commands/umami/deploy.test.ts +0 -202
  79. package/src/commands/umami/deploy.ts +0 -132
  80. package/src/commands/umami/host.test.ts +0 -79
  81. package/src/commands/umami/host.ts +0 -33
  82. package/src/commands/umami/index.ts +0 -13
  83. package/src/commands/umami/logs.test.ts +0 -191
  84. package/src/commands/umami/logs.ts +0 -163
  85. package/src/commands/umami/status.test.ts +0 -633
  86. package/src/commands/umami/status.ts +0 -285
  87. package/src/commands/vpn/client.test.ts +0 -949
  88. package/src/commands/vpn/client.ts +0 -467
  89. package/src/commands/vpn/deploy.test.ts +0 -208
  90. package/src/commands/vpn/deploy.ts +0 -143
  91. package/src/commands/vpn/host.test.ts +0 -89
  92. package/src/commands/vpn/host.ts +0 -36
  93. package/src/commands/vpn/index.ts +0 -15
  94. package/src/commands/vpn/logs.test.ts +0 -234
  95. package/src/commands/vpn/logs.ts +0 -136
  96. package/src/commands/vpn/peers.test.ts +0 -478
  97. package/src/commands/vpn/shared.test.ts +0 -454
  98. package/src/commands/vpn/shared.ts +0 -170
  99. package/src/commands/vpn/status.test.ts +0 -142
  100. package/src/commands/vpn/status.ts +0 -78
  101. package/src/conventions.test.ts +0 -1180
  102. package/src/lib/action-ctx.ts +0 -25
  103. package/src/lib/known-hosts.test.ts +0 -192
  104. package/src/lib/known-hosts.ts +0 -76
  105. package/src/lib/redact.ts +0 -21
  106. package/src/lib/ssh-identity.test.ts +0 -134
  107. package/src/lib/ssh-identity.ts +0 -76
  108. /package/{src/resources/known_hosts → dist/known_hosts-tk724cp0.} +0 -0
@@ -1,1180 +0,0 @@
1
- import {relative, resolve} from 'node:path'
2
- import {describe, expect, it} from 'bun:test'
3
- import {parse as parseYaml} from 'yaml'
4
- import {MCP_ALLOWLIST} from './commands/mcp'
5
-
6
- const REPO_ROOT = resolve(import.meta.dir, '../../..')
7
- const INTERNAL_ORGS = new Set(['marcusrbrown'])
8
- const ALLOWED_SHELL_SCRIPTS = new Set(['apps/keeweb/deploy.sh'])
9
-
10
- // ---------------------------------------------------------------------------
11
- // MCP drift-guard: sensitive tool set (two-layer security model)
12
- // ---------------------------------------------------------------------------
13
- //
14
- // These commands are source-gated: they are NOT in MCP_ALLOWLIST and are
15
- // therefore never registered as MCP tools. They remain CLI-only.
16
- //
17
- // WHY each is source-gated (primary layer — MCP_ALLOWLIST exclusion):
18
- // cliproxy keys add — mutating: creates live bearer tokens on the proxy
19
- // cliproxy keys remove — mutating: revokes live bearer tokens
20
- // cliproxy config set — mutating: overwrites CLIProxyAPI runtime config
21
- // gateway backup — secret-bearing: writes CA private key material to a tarball
22
- // cliproxy keys list — secret-disclosing: prints live bearer tokens in plaintext
23
- // cliproxy config get — secret-disclosing: dumps management config incl. management key
24
- // vpn deploy — mutating: deploys WireGuard config to live VPN box
25
- // vpn logs — sensitive: streams journalctl logs that may reveal peer IPs/traffic
26
- // vpn client add — mutating: generates keypair + appends peer + triggers redeploy
27
- // vpn client list — sensitive: lists peer public keys and tunnel IPs
28
- // vpn client remove — mutating: removes peer + triggers redeploy
29
- //
30
- // Defense-in-depth (secondary layer — opencode.jsonc `permission: deny`):
31
- // Even if MCP_ALLOWLIST were mistakenly re-expanded, opencode's native tool
32
- // permission check provides a backstop that denies these tool calls centrally
33
- // before execution. Both layers must stay in sync; the test below enforces it.
34
- //
35
- // A conventions test below asserts every entry (a) is NOT in MCP_ALLOWLIST
36
- // (source-gated out) and (b) is still denied in opencode.jsonc under the
37
- // prefixed tool id (defense-in-depth backstop).
38
- const SENSITIVE_MCP_COMMANDS: readonly string[] = [
39
- 'cliproxy keys add',
40
- 'cliproxy keys remove',
41
- 'cliproxy config set',
42
- 'gateway backup',
43
- 'cliproxy keys list',
44
- 'cliproxy config get',
45
- // VPN: mutating / sensitive / log-streaming — CLI-only
46
- 'vpn deploy',
47
- 'vpn logs',
48
- 'vpn client add',
49
- 'vpn client list',
50
- 'vpn client remove',
51
- ]
52
-
53
- // Accepted version-comment forms on SHA-pinned `uses:` lines:
54
- // # v6.0.2 (semver tag)
55
- // # renovate-changesets@0.2.31 (scoped release tag)
56
- const VERSION_COMMENT_RE = /^#\s+(?:v\d+(?:\.\d+){0,2}|[\w@/-]+@\d+(?:\.\d+){0,2})\s*$/
57
-
58
- // Match `[- ]uses: owner/repo@<sha>[ trailing]` — step-level (indented, possibly dash-prefixed) and job-level.
59
- // Uses [ \t] instead of \s to avoid regex backtracking ambiguity between overlapping quantifiers.
60
- const USES_SHA_LINE_RE = /^[ \t]+(?:-[ \t]+)?uses:[ \t]+(\S+)@([a-f0-9]{7,})(?:[ \t]+(\S.*))?$/
61
-
62
- interface Violation {
63
- file: string
64
- detail: string
65
- }
66
-
67
- // `.github/` is a dot-directory; Bun.Glob skips dot-dirs by default, so every
68
- // glob that traverses `.github/` must pass `{ dot: true }`. Without it, the
69
- // workflow rules silently pass on an empty file set. The tripwire test at the
70
- // top of the suite asserts the file count is >= 1 to catch this regression.
71
- function listWorkflowFiles(extension: '.yaml' | '.yml'): string[] {
72
- const glob = new Bun.Glob(`.github/workflows/*${extension}`)
73
- return [...glob.scanSync({cwd: REPO_ROOT, absolute: true, dot: true})]
74
- }
75
-
76
- function listPackageJsonFiles(): string[] {
77
- // Bun.Glob auto-excludes node_modules/** by default; no package.json lives under dot-dirs.
78
- const glob = new Bun.Glob('**/package.json')
79
- return [...glob.scanSync({cwd: REPO_ROOT, absolute: true})].filter(f => !f.includes('/node_modules/'))
80
- }
81
-
82
- function listShellScriptFiles(): string[] {
83
- const glob = new Bun.Glob('**/*.sh')
84
- return [...glob.scanSync({cwd: REPO_ROOT, absolute: true})].filter(
85
- f => !f.includes('/node_modules/') && !f.includes('/.cache/') && !f.includes('/dist/'),
86
- )
87
- }
88
-
89
- /**
90
- * Detect cross-org `secrets: inherit` on reusable-workflow job calls.
91
- *
92
- * Rules:
93
- * - Jobs without a `uses:` string are step-based and out of scope.
94
- * - `uses:` values starting with `./` or `../` are local reusable workflows; `secrets: inherit` is legitimate.
95
- * - `uses:` values whose owner (first path segment) is in INTERNAL_ORGS are same-org; `secrets: inherit` is legitimate.
96
- * - Everything else is cross-org and must not use `secrets: inherit`.
97
- */
98
- export function findCrossOrgSecretsInherit(parsed: unknown): {jobId: string; uses: string}[] {
99
- if (typeof parsed !== 'object' || parsed === null) return []
100
- const jobs = (parsed as {jobs?: Record<string, unknown>}).jobs
101
- if (typeof jobs !== 'object' || jobs === null) return []
102
-
103
- const violations: {jobId: string; uses: string}[] = []
104
- for (const [jobId, jobRaw] of Object.entries(jobs)) {
105
- if (typeof jobRaw !== 'object' || jobRaw === null) continue
106
- const job = jobRaw as {uses?: unknown; secrets?: unknown}
107
- if (typeof job.uses !== 'string') continue
108
- if (job.uses.startsWith('./') || job.uses.startsWith('../')) continue
109
- const owner = job.uses.split('/')[0] ?? ''
110
- if (INTERNAL_ORGS.has(owner)) continue
111
- if (job.secrets === 'inherit') {
112
- violations.push({jobId, uses: job.uses})
113
- }
114
- }
115
- return violations
116
- }
117
-
118
- /**
119
- * Detect dorny/paths-filter steps that use negation patterns without declaring
120
- * `predicate-quantifier: every`. The default quantifier (`some`) applies OR-logic
121
- * across patterns, which silently makes negations truthy whenever any other file
122
- * matches — the opposite of the intended behaviour.
123
- *
124
- * Rule: any step using dorny/paths-filter that contains a filter pattern starting
125
- * with `!` MUST also set `predicate-quantifier: every` in the same step's `with:` block.
126
- */
127
- export interface PathsFilterQuantifierViolation {
128
- file?: string
129
- jobId: string
130
- stepIndex: number
131
- reason: string
132
- }
133
-
134
- export function findPathsFilterQuantifierViolations(workflowText: string): PathsFilterQuantifierViolation[] {
135
- const parsed = parseYaml(workflowText, {merge: true}) as unknown
136
- if (typeof parsed !== 'object' || parsed === null) return []
137
- const jobs = (parsed as {jobs?: Record<string, unknown>}).jobs
138
- if (typeof jobs !== 'object' || jobs === null) return []
139
-
140
- const violations: PathsFilterQuantifierViolation[] = []
141
-
142
- for (const [jobId, jobRaw] of Object.entries(jobs)) {
143
- if (typeof jobRaw !== 'object' || jobRaw === null) continue
144
- const job = jobRaw as {steps?: unknown[]}
145
- if (!Array.isArray(job.steps)) continue
146
-
147
- for (const [index, stepRaw] of job.steps.entries()) {
148
- if (typeof stepRaw !== 'object' || stepRaw === null) continue
149
- const step = stepRaw as {uses?: unknown; with?: Record<string, unknown>}
150
- if (typeof step.uses !== 'string') continue
151
- if (!step.uses.startsWith('dorny/paths-filter')) continue
152
-
153
- const withBlock = step.with ?? {}
154
- const filtersRaw = withBlock.filters
155
- if (typeof filtersRaw !== 'string') continue
156
-
157
- // Parse the inner YAML of the filters block to inspect pattern lists
158
- const filters = parseYaml(filtersRaw, {merge: true}) as unknown
159
- if (typeof filters !== 'object' || filters === null) continue
160
-
161
- let hasNegation = false
162
- for (const patternsRaw of Object.values(filters as Record<string, unknown>)) {
163
- const patterns = Array.isArray(patternsRaw) ? patternsRaw : [patternsRaw]
164
- for (const p of patterns) {
165
- if (typeof p === 'string' && p.startsWith('!')) {
166
- hasNegation = true
167
- break
168
- }
169
- }
170
- if (hasNegation) break
171
- }
172
-
173
- if (!hasNegation) continue
174
-
175
- const quantifier = withBlock['predicate-quantifier']
176
- if (quantifier !== 'every') {
177
- violations.push({
178
- jobId,
179
- stepIndex: index,
180
- reason:
181
- quantifier === undefined
182
- ? `job '${jobId}' step ${index} uses dorny/paths-filter with negation patterns but is missing predicate-quantifier: every`
183
- : `job '${jobId}' step ${index} uses dorny/paths-filter with negation patterns but predicate-quantifier is '${String(quantifier)}' (must be 'every')`,
184
- })
185
- }
186
- }
187
- }
188
-
189
- return violations
190
- }
191
-
192
- describe('repo conventions', () => {
193
- it('tripwire: workflow glob resolves to at least one file (catches dot-dir glob regressions)', () => {
194
- const workflows = listWorkflowFiles('.yaml')
195
- expect(workflows.length).toBeGreaterThan(0)
196
- })
197
-
198
- it('no `bundledDependencies` in any package.json', async () => {
199
- const files = listPackageJsonFiles()
200
- const offenders: string[] = []
201
- for (const file of files) {
202
- const json = (await Bun.file(file).json()) as Record<string, unknown>
203
- if ('bundledDependencies' in json) {
204
- offenders.push(relative(REPO_ROOT, file))
205
- }
206
- }
207
- expect(offenders).toEqual([])
208
- })
209
-
210
- it("apps/keeweb/config/config.json has settings.dropboxSecret === ''", async () => {
211
- const configPath = resolve(REPO_ROOT, 'apps/keeweb/config/config.json')
212
- const config = (await Bun.file(configPath).json()) as {
213
- settings?: {dropboxSecret?: unknown}
214
- }
215
- expect(config.settings?.dropboxSecret).toBe('')
216
- })
217
-
218
- it('no `secrets: inherit` on any job whose `uses:` points to a cross-org workflow', async () => {
219
- const files = listWorkflowFiles('.yaml')
220
- const violations: Violation[] = []
221
- for (const file of files) {
222
- const text = await Bun.file(file).text()
223
- const parsed = parseYaml(text, {merge: true})
224
- for (const v of findCrossOrgSecretsInherit(parsed)) {
225
- violations.push({
226
- file: relative(REPO_ROOT, file),
227
- detail: `job '${v.jobId}' uses '${v.uses}' with secrets: inherit`,
228
- })
229
- }
230
- }
231
- expect(violations).toEqual([])
232
- })
233
-
234
- it('no `ssh-keyscan` under .github/workflows/**', async () => {
235
- const files = listWorkflowFiles('.yaml')
236
- const violations: Violation[] = []
237
- for (const file of files) {
238
- const text = await Bun.file(file).text()
239
- const lines = text.split(/\r?\n/)
240
- for (const [index, line] of lines.entries()) {
241
- if (/\bssh-keyscan\b/.test(line)) {
242
- violations.push({
243
- file: relative(REPO_ROOT, file),
244
- detail: `line ${index + 1}: ${line.trim()}`,
245
- })
246
- }
247
- }
248
- }
249
- expect(violations).toEqual([])
250
- })
251
-
252
- it('every SHA-pinned `uses:` line has a trailing `# vX.Y.Z` or `# scope@X.Y.Z` comment', async () => {
253
- const files = listWorkflowFiles('.yaml')
254
- const violations: Violation[] = []
255
- for (const file of files) {
256
- const text = await Bun.file(file).text()
257
- const lines = text.split(/\r?\n/)
258
- for (const [index, line] of lines.entries()) {
259
- const match = line.match(USES_SHA_LINE_RE)
260
- if (!match) continue
261
- const [, ref = '', sha = '', tail] = match
262
- const shortSha = sha.slice(0, 7)
263
- if (tail === undefined) {
264
- violations.push({
265
- file: relative(REPO_ROOT, file),
266
- detail: `line ${index + 1}: missing version comment on '${ref}@${shortSha}…'`,
267
- })
268
- } else if (!VERSION_COMMENT_RE.test(tail.trim())) {
269
- violations.push({
270
- file: relative(REPO_ROOT, file),
271
- detail: `line ${index + 1}: malformed version comment '${tail.trim()}' on '${ref}@${shortSha}…'`,
272
- })
273
- }
274
- }
275
- }
276
- expect(violations).toEqual([])
277
- })
278
-
279
- it('no `.yml` files under .github/workflows/ (use `.yaml`)', () => {
280
- const files = listWorkflowFiles('.yml')
281
- expect(files.map(f => relative(REPO_ROOT, f))).toEqual([])
282
- })
283
-
284
- it('no `.sh` files outside `apps/keeweb/deploy.sh`', () => {
285
- const files = listShellScriptFiles()
286
- const offenders = files.map(f => relative(REPO_ROOT, f)).filter(f => !ALLOWED_SHELL_SCRIPTS.has(f))
287
- expect(offenders).toEqual([])
288
- })
289
-
290
- it('opencode.jsonc mcp.infra.command uses local repo source (not bunx published package)', async () => {
291
- const jsoncText = await Bun.file(resolve(REPO_ROOT, 'opencode.jsonc')).text()
292
- const stripped = jsoncText
293
- .split('\n')
294
- .map(line => (/^\s*\/\//.test(line) ? '' : line))
295
- .join('\n')
296
- .replaceAll(/,(\s*[}\]])/g, '$1')
297
- const opencode = JSON.parse(stripped) as {mcp?: {infra?: {command?: unknown}}}
298
-
299
- const infraCommand = opencode.mcp?.infra?.command
300
- expect(Array.isArray(infraCommand), 'mcp.infra.command must be an array').toBe(true)
301
-
302
- const cmd = infraCommand as string[]
303
- expect(
304
- cmd,
305
- 'mcp.infra.command must be ["bun", "run", "packages/cli/src/cli.ts", "mcp"] — use local source, not bunx',
306
- ).toEqual(['bun', 'run', 'packages/cli/src/cli.ts', 'mcp'])
307
-
308
- // Explicit regression guard: bunx @marcusrbrown/infra resolves stale published
309
- // package cache and exits before the MCP handshake, causing connection closed.
310
- expect(cmd.join(' ')).not.toContain('bunx')
311
- expect(cmd.join(' ')).not.toContain('@marcusrbrown/infra')
312
- })
313
-
314
- it('gates every sensitive infra MCP tool in opencode.jsonc', async () => {
315
- // Parse opencode.jsonc tolerantly: it uses JSONC syntax (// line comments,
316
- // trailing commas). Strategy:
317
- // 1. Strip full-line comments — lines whose first non-whitespace is `//`.
318
- // This avoids breaking the `https://` in the $schema string value.
319
- // 2. Strip trailing commas before `}` or `]`.
320
- // 3. JSON.parse the result.
321
- const jsoncText = await Bun.file(resolve(REPO_ROOT, 'opencode.jsonc')).text()
322
- const stripped = jsoncText
323
- .split('\n')
324
- .map(line => (/^\s*\/\//.test(line) ? '' : line))
325
- .join('\n')
326
- .replaceAll(/,(\s*[}\]])/g, '$1')
327
- const opencode = JSON.parse(stripped) as {permission?: Record<string, unknown>}
328
-
329
- // Parse sanity: permission block must be present (catches a broken parse).
330
- // opencode permission-checks MCP tool calls via the `permission` map (tool ids
331
- // `<server>_<tool>`); we assert the canonical `permission: "deny"` form here.
332
- const permission = opencode.permission ?? {}
333
- expect(Object.keys(permission).length).toBeGreaterThan(0)
334
-
335
- const violations: string[] = []
336
- for (const cmd of SENSITIVE_MCP_COMMANDS) {
337
- // (a) Primary gate: the command must NOT be in MCP_ALLOWLIST (source-gated out).
338
- // If someone re-adds it to the allowlist, this catches the regression immediately.
339
- if (MCP_ALLOWLIST.has(cmd)) {
340
- violations.push(
341
- `'${cmd}' is in SENSITIVE_MCP_COMMANDS AND in MCP_ALLOWLIST — source-gate regression: remove it from MCP_ALLOWLIST`,
342
- )
343
- }
344
- // (b) Defense-in-depth backstop: the tool must still be denied in opencode.jsonc.
345
- // This catches the case where MCP_ALLOWLIST is mistakenly re-expanded.
346
- // The infra MCP server converts command names to tool names with underscores,
347
- // and OpenCode prefixes with the server name, giving "infra_<underscored>".
348
- const toolId = `infra_${cmd.replaceAll(' ', '_')}`
349
- if (permission[toolId] !== 'deny') {
350
- violations.push(
351
- `${toolId}: not denied in opencode.jsonc permission (found: ${JSON.stringify(permission[toolId])}) — defense-in-depth backstop missing`,
352
- )
353
- }
354
- }
355
- expect(
356
- violations,
357
- `Sensitive infra MCP tools must be source-gated (not in MCP_ALLOWLIST) and denied in opencode.jsonc:\n${violations.join('\n')}`,
358
- ).toEqual([])
359
- })
360
- })
361
-
362
- // ---------------------------------------------------------------------------
363
- // (enforced) marker drift detection
364
- // ---------------------------------------------------------------------------
365
- //
366
- // Every bullet in AGENTS.md tagged `(enforced)` must map to a known
367
- // enforcement mechanism (test or ESLint rule). Adding `(enforced)` without
368
- // backing enforcement, or deleting the enforcement while keeping the marker,
369
- // both cause a test failure here.
370
- //
371
- // Manifest keys are unique substrings of the AGENTS.md bullet text.
372
- // Values describe where the enforcement lives — they are documentation only.
373
- const ENFORCED_MANIFEST: Record<string, string> = {
374
- 'Only bash script': 'conventions.test.ts: no .sh files outside apps/keeweb/deploy.sh',
375
- 'GitHub Actions': 'conventions.test.ts: .yaml extension + SHA-pin version comment',
376
- 'Cross-org reusable workflows': 'conventions.test.ts: no secrets: inherit on cross-org jobs',
377
- 'as any': 'eslint.config.ts: @typescript-eslint/no-explicit-any + ban-ts-comment at error',
378
- 'No secret values in tracked files': 'conventions.test.ts: settings.dropboxSecret === empty string',
379
- 'ssh-keyscan': 'conventions.test.ts: no ssh-keyscan under .github/workflows/**',
380
- 'Never `secrets: inherit`': 'conventions.test.ts: no secrets: inherit on cross-org jobs',
381
- bundledDependencies: 'conventions.test.ts: no bundledDependencies in any package.json',
382
- }
383
-
384
- describe('(enforced) marker drift', () => {
385
- it('every (enforced) bullet in AGENTS.md is accounted for in the enforcement manifest', async () => {
386
- const agentsMd = await Bun.file(resolve(REPO_ROOT, 'AGENTS.md')).text()
387
- const enforcedLines = agentsMd.split(/\r?\n/).filter((l: string) => /\(enforced\)/.test(l))
388
-
389
- // Tripwire — if the grep logic breaks, the whole suite silently passes with 0 checks
390
- expect(enforcedLines.length).toBeGreaterThan(0)
391
-
392
- const unmatched = enforcedLines.filter(
393
- (line: string) => !Object.keys(ENFORCED_MANIFEST).some((key: string) => line.includes(key)),
394
- )
395
- expect(unmatched, 'New (enforced) bullet has no manifest entry — add enforcement before tagging').toEqual([])
396
- })
397
-
398
- it('every manifest entry corresponds to an actual (enforced) bullet in AGENTS.md', async () => {
399
- const agentsMd = await Bun.file(resolve(REPO_ROOT, 'AGENTS.md')).text()
400
- const enforcedLines = agentsMd.split(/\r?\n/).filter((l: string) => /\(enforced\)/.test(l))
401
-
402
- const stale = Object.keys(ENFORCED_MANIFEST).filter(
403
- (key: string) => !enforcedLines.some((l: string) => l.includes(key)),
404
- )
405
- expect(stale, 'Manifest entry has no matching (enforced) bullet in AGENTS.md — remove or update').toEqual([])
406
- })
407
-
408
- it('@typescript-eslint/no-explicit-any is configured at error severity in eslint.config.ts', async () => {
409
- const eslintConfig = await Bun.file(resolve(REPO_ROOT, 'eslint.config.ts')).text()
410
- // Matches: '@typescript-eslint/no-explicit-any': 'error'
411
- expect(eslintConfig).toMatch(/'@typescript-eslint\/no-explicit-any'\s*:\s*'error'/)
412
- })
413
-
414
- it('@typescript-eslint/ban-ts-comment is configured in eslint.config.ts', async () => {
415
- const eslintConfig = await Bun.file(resolve(REPO_ROOT, 'eslint.config.ts')).text()
416
- expect(eslintConfig).toContain('@typescript-eslint/ban-ts-comment')
417
- })
418
- })
419
-
420
- // ---------------------------------------------------------------------------
421
- // Per-app invariants
422
- // ---------------------------------------------------------------------------
423
- //
424
- // Guard that critical safety mechanisms in each app are not accidentally
425
- // removed. These are the runtime equivalents of (enforced) markers: code that
426
- // must remain present for the documented behaviour to hold.
427
-
428
- describe('per-app invariants', () => {
429
- it('cliproxy deploy.ts guards config.yaml upload with a remoteFileExists() check', async () => {
430
- const deployTs = await Bun.file(resolve(REPO_ROOT, 'apps/cliproxy/src/deploy.ts')).text()
431
- // The guard: remoteFileExists(host, `${REMOTE_DIR}/config/config.yaml`, env)
432
- // This prevents overwriting runtime API keys on the server.
433
- expect(deployTs).toContain('remoteFileExists')
434
- expect(deployTs).toContain('config.yaml')
435
- })
436
-
437
- it('keeweb build.ts defines an EXPECTED_SHA256 constant for archive integrity', async () => {
438
- const buildTs = await Bun.file(resolve(REPO_ROOT, 'apps/keeweb/src/build.ts')).text()
439
- // EXPECTED_SHA256 is the KeeWeb release zip checksum; its presence proves
440
- // SHA verification is wired in and was not accidentally stripped.
441
- expect(buildTs).toMatch(/const\s+EXPECTED_SHA256\s*=/)
442
- })
443
-
444
- it('deploy-gateway.yaml does NOT install doctl (firewall is provisioning-only, not deploy-time)', async () => {
445
- const text = await Bun.file(resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')).text()
446
- // The DO Cloud Firewall is created in provisioning (provision-droplet.ts), not in the deploy hot path.
447
- // The deploy job must NOT install doctl — it no longer needs it.
448
- expect(text).not.toMatch(/uses:\s+digitalocean\/action-doctl@[a-f0-9]{40}/)
449
- expect(text).not.toContain('digitalocean/action-doctl')
450
- })
451
-
452
- it('deploy-gateway.yaml does NOT forward DIGITALOCEAN_ACCESS_TOKEN to the deploy step', async () => {
453
- const text = await Bun.file(resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')).text()
454
- // DIGITALOCEAN_ACCESS_TOKEN is only needed for provisioning, not for the deploy hot path.
455
- // Verify it is not forwarded to the deploy step env block.
456
- const deployStepIndex = text.indexOf('name: Deploy gateway')
457
- expect(deployStepIndex).toBeGreaterThan(-1)
458
- const afterDeployStep = text.slice(deployStepIndex)
459
- expect(afterDeployStep).not.toContain('DIGITALOCEAN_ACCESS_TOKEN')
460
- })
461
-
462
- it('deploy-gateway.yaml still forwards GATEWAY_VPC_IP and DASHBOARD_VPC_IP to the deploy step', async () => {
463
- const text = await Bun.file(resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')).text()
464
- // GATEWAY_VPC_IP and DASHBOARD_VPC_IP are still needed by deploy.ts for the compose VPC-IP
465
- // publish and the DOCKER-USER iptables rule.
466
- const deployStepIndex = text.indexOf('name: Deploy gateway')
467
- expect(deployStepIndex).toBeGreaterThan(-1)
468
- const afterDeployStep = text.slice(deployStepIndex)
469
- expect(afterDeployStep).toContain('GATEWAY_VPC_IP')
470
- expect(afterDeployStep).toContain('DASHBOARD_VPC_IP')
471
- })
472
- })
473
-
474
- describe('findCrossOrgSecretsInherit', () => {
475
- it('flags a cross-org reusable workflow that uses `secrets: inherit`', () => {
476
- const parsed = parseYaml(`
477
- jobs:
478
- build:
479
- uses: bfra-me/.github/.github/workflows/example.yaml@abc1234
480
- secrets: inherit
481
- `)
482
- const violations = findCrossOrgSecretsInherit(parsed)
483
- expect(violations).toHaveLength(1)
484
- expect(violations[0]?.jobId).toBe('build')
485
- expect(violations[0]?.uses).toContain('bfra-me/')
486
- })
487
-
488
- it('allows a same-org reusable workflow to use `secrets: inherit`', () => {
489
- const parsed = parseYaml(`
490
- jobs:
491
- release:
492
- uses: marcusrbrown/infra/.github/workflows/release.yaml@sha
493
- secrets: inherit
494
- `)
495
- expect(findCrossOrgSecretsInherit(parsed)).toEqual([])
496
- })
497
-
498
- it('allows a local (./) reusable workflow to use `secrets: inherit`', () => {
499
- const parsed = parseYaml(`
500
- jobs:
501
- build:
502
- uses: ./.github/workflows/local.yaml
503
- secrets: inherit
504
- `)
505
- expect(findCrossOrgSecretsInherit(parsed)).toEqual([])
506
- })
507
-
508
- it('ignores `secrets: inherit` strings appearing inside prose block scalars', () => {
509
- // Mirrors fro-bot.yaml: SCHEDULE_PROMPT contains the literal string
510
- // `secrets: inherit` inside a prompt heredoc, but no job-level key exists.
511
- const parsed = parseYaml(`
512
- env:
513
- SCHEDULE_PROMPT: |
514
- Remember: never use secrets: inherit with cross-org workflows.
515
- jobs:
516
- run:
517
- runs-on: ubuntu-latest
518
- steps:
519
- - run: echo hi
520
- `)
521
- expect(findCrossOrgSecretsInherit(parsed)).toEqual([])
522
- })
523
-
524
- it('does not flag jobs without a `uses:` key (step-based jobs)', () => {
525
- const parsed = parseYaml(`
526
- jobs:
527
- lint:
528
- runs-on: ubuntu-latest
529
- steps:
530
- - run: echo hi
531
- `)
532
- expect(findCrossOrgSecretsInherit(parsed)).toEqual([])
533
- })
534
- })
535
-
536
- describe('findPathsFilterQuantifierViolations', () => {
537
- it('paths-filter with negations and predicate-quantifier: every → 0 violations', () => {
538
- const yaml = `
539
- jobs:
540
- detect:
541
- runs-on: ubuntu-latest
542
- steps:
543
- - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
544
- with:
545
- predicate-quantifier: every
546
- filters: |
547
- app:
548
- - 'apps/myapp/**'
549
- - '!apps/myapp/**/*.md'
550
- `
551
- expect(findPathsFilterQuantifierViolations(yaml)).toEqual([])
552
- })
553
-
554
- it('paths-filter with negations and missing predicate-quantifier → 1 violation', () => {
555
- const yaml = `
556
- jobs:
557
- detect:
558
- runs-on: ubuntu-latest
559
- steps:
560
- - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
561
- with:
562
- filters: |
563
- app:
564
- - 'apps/myapp/**'
565
- - '!apps/myapp/**/*.md'
566
- `
567
- const violations = findPathsFilterQuantifierViolations(yaml)
568
- expect(violations).toEqual([
569
- {
570
- jobId: 'detect',
571
- stepIndex: 0,
572
- reason: `job 'detect' step 0 uses dorny/paths-filter with negation patterns but is missing predicate-quantifier: every`,
573
- },
574
- ])
575
- })
576
-
577
- it('paths-filter with negations and predicate-quantifier: some → 1 violation', () => {
578
- const yaml = `
579
- jobs:
580
- detect:
581
- runs-on: ubuntu-latest
582
- steps:
583
- - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
584
- with:
585
- predicate-quantifier: some
586
- filters: |
587
- app:
588
- - 'apps/myapp/**'
589
- - '!apps/myapp/**/*.md'
590
- `
591
- const violations = findPathsFilterQuantifierViolations(yaml)
592
- expect(violations).toEqual([
593
- {
594
- jobId: 'detect',
595
- stepIndex: 0,
596
- reason: `job 'detect' step 0 uses dorny/paths-filter with negation patterns but predicate-quantifier is 'some' (must be 'every')`,
597
- },
598
- ])
599
- })
600
-
601
- it('paths-filter without negations → 0 violations regardless of quantifier', () => {
602
- const yaml = `
603
- jobs:
604
- detect:
605
- runs-on: ubuntu-latest
606
- steps:
607
- - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
608
- with:
609
- filters: |
610
- app:
611
- - 'apps/myapp/**'
612
- - 'apps/myapp/**/*.ts'
613
- `
614
- expect(findPathsFilterQuantifierViolations(yaml)).toEqual([])
615
- })
616
-
617
- it('bare-string negation filter without predicate-quantifier → 1 violation', () => {
618
- const yaml = `
619
- jobs:
620
- detect:
621
- runs-on: ubuntu-latest
622
- steps:
623
- - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
624
- with:
625
- filters: |
626
- cliproxy: '!apps/cliproxy/**/*.md'
627
- `
628
- const violations = findPathsFilterQuantifierViolations(yaml)
629
- expect(violations).toEqual([
630
- {
631
- jobId: 'detect',
632
- stepIndex: 0,
633
- reason: `job 'detect' step 0 uses dorny/paths-filter with negation patterns but is missing predicate-quantifier: every`,
634
- },
635
- ])
636
- })
637
-
638
- it('bare-string negation filter with predicate-quantifier: every → 0 violations', () => {
639
- const yaml = `
640
- jobs:
641
- detect:
642
- runs-on: ubuntu-latest
643
- steps:
644
- - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
645
- with:
646
- predicate-quantifier: every
647
- filters: |
648
- cliproxy: '!apps/cliproxy/**/*.md'
649
- `
650
- expect(findPathsFilterQuantifierViolations(yaml)).toEqual([])
651
- })
652
- })
653
-
654
- describe('dorny/paths-filter quantifier guard', () => {
655
- it('tripwire: workflow glob resolves to at least one file (catches dot-dir glob regressions)', () => {
656
- // `.github/` is a dot-directory; Bun.Glob skips dot-dirs by default unless `dot: true` is set.
657
- const glob = new Bun.Glob('.github/workflows/**')
658
- const files = [...glob.scanSync({cwd: REPO_ROOT, absolute: true, dot: true})]
659
- expect(files.length).toBeGreaterThan(0)
660
- })
661
-
662
- it('all workflow files using dorny/paths-filter with negations declare predicate-quantifier: every', async () => {
663
- const files = listWorkflowFiles('.yaml')
664
- expect(files.length).toBeGreaterThan(0)
665
-
666
- const violations: PathsFilterQuantifierViolation[] = []
667
- for (const file of files) {
668
- const text = await Bun.file(file).text()
669
- for (const v of findPathsFilterQuantifierViolations(text)) {
670
- violations.push({...v, file: relative(REPO_ROOT, file)})
671
- }
672
- }
673
- expect(violations).toEqual([])
674
- })
675
- })
676
-
677
- // ─── operator auth/config secrets and tuning vars ────────────────────────────
678
- //
679
- // These assert the four operator auth/config secrets and three optional tuning vars are
680
- // wired through deploy-gateway.yaml (workflow_call.secrets + inputs + deploy step env),
681
- // deploy.yaml (fan-out secrets + with inputs), and the CLI passthrough.
682
-
683
- const OPERATOR_AUTH_SECRETS = [
684
- 'GATEWAY_OPERATOR_GITHUB_CLIENT_ID',
685
- 'GATEWAY_OPERATOR_GITHUB_CLIENT_SECRET',
686
- 'GATEWAY_OPERATOR_CSRF_SECRET',
687
- 'GATEWAY_OPERATOR_ALLOWLIST',
688
- ] as const
689
-
690
- const OPERATOR_TUNING_SECRETS = [
691
- 'GATEWAY_OPERATOR_OAUTH_ALLOWED_RETURN_PATHS',
692
- 'GATEWAY_OPERATOR_OAUTH_STATE_TTL_MS',
693
- 'GATEWAY_OPERATOR_OAUTH_MAX_OUTSTANDING_ATTEMPTS',
694
- ] as const
695
-
696
- describe('deploy-gateway.yaml: operator auth/config secrets in workflow_call.secrets', () => {
697
- const DEPLOY_GATEWAY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')
698
-
699
- for (const secret of OPERATOR_AUTH_SECRETS) {
700
- it(`workflow_call.secrets declares ${secret} as optional (required: false)`, async () => {
701
- const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
702
- const parsed = parseYaml(text) as {on?: {workflow_call?: {secrets?: Record<string, {required?: boolean}>}}}
703
- const secrets = parsed?.on?.workflow_call?.secrets ?? {}
704
- expect(secrets).toHaveProperty(secret)
705
- expect(secrets[secret]?.required).toBe(false)
706
- })
707
- }
708
- })
709
-
710
- describe('deploy-gateway.yaml: operator tuning vars in workflow_call.secrets', () => {
711
- const DEPLOY_GATEWAY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')
712
-
713
- for (const secret of OPERATOR_TUNING_SECRETS) {
714
- it(`workflow_call.secrets declares ${secret} as optional (required: false)`, async () => {
715
- const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
716
- const parsed = parseYaml(text) as {on?: {workflow_call?: {secrets?: Record<string, {required?: boolean}>}}}
717
- const secrets = parsed?.on?.workflow_call?.secrets ?? {}
718
- expect(secrets).toHaveProperty(secret)
719
- expect(secrets[secret]?.required).toBe(false)
720
- })
721
- }
722
-
723
- it('workflow_call.inputs does NOT declare any GATEWAY_OPERATOR_OAUTH_ tuning vars', async () => {
724
- const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
725
- const parsed = parseYaml(text) as {on?: {workflow_call?: {inputs?: Record<string, unknown>}}}
726
- const inputs = parsed?.on?.workflow_call?.inputs ?? {}
727
- for (const secret of OPERATOR_TUNING_SECRETS) {
728
- expect(inputs).not.toHaveProperty(secret)
729
- }
730
- })
731
- })
732
-
733
- describe('deploy-gateway.yaml: deploy step env forwards operator auth/config vars', () => {
734
- const DEPLOY_GATEWAY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')
735
-
736
- for (const secret of OPERATOR_AUTH_SECRETS) {
737
- it(`Deploy gateway step env forwards ${secret}`, async () => {
738
- const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
739
- const parsed = parseYaml(text) as {
740
- jobs?: {'deploy-gateway'?: {steps?: {name?: string; env?: Record<string, string>}[]}}
741
- }
742
- const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
743
- const deployStep = steps.find(s => s.name === 'Deploy gateway')
744
- expect(deployStep).toBeDefined()
745
- expect(deployStep?.env).toHaveProperty(secret)
746
- expect(deployStep?.env?.[secret]).toContain(secret)
747
- })
748
- }
749
-
750
- for (const secret of OPERATOR_TUNING_SECRETS) {
751
- it(`Deploy gateway step env forwards ${secret} from secrets context (not inputs)`, async () => {
752
- const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
753
- const parsed = parseYaml(text) as {
754
- jobs?: {'deploy-gateway'?: {steps?: {name?: string; env?: Record<string, string>}[]}}
755
- }
756
- const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
757
- const deployStep = steps.find(s => s.name === 'Deploy gateway')
758
- expect(deployStep).toBeDefined()
759
- expect(deployStep?.env).toHaveProperty(secret)
760
- // Must source from secrets context, not inputs context
761
- expect(deployStep?.env?.[secret]).toMatch(/\$\{\{\s*secrets\./)
762
- })
763
- }
764
- })
765
-
766
- describe('deploy.yaml: fan-out passes operator auth/config secrets to deploy-gateway job', () => {
767
- const DEPLOY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy.yaml')
768
-
769
- for (const secret of OPERATOR_AUTH_SECRETS) {
770
- it(`deploy-gateway job secrets block passes ${secret}`, async () => {
771
- const text = await Bun.file(DEPLOY_WORKFLOW).text()
772
- const parsed = parseYaml(text) as {
773
- jobs?: {'deploy-gateway'?: {secrets?: Record<string, string>}}
774
- }
775
- const secrets = parsed?.jobs?.['deploy-gateway']?.secrets ?? {}
776
- expect(secrets).toHaveProperty(secret)
777
- expect(secrets[secret]).toContain(secret)
778
- })
779
- }
780
- })
781
-
782
- describe('deploy.yaml: fan-out passes operator tuning vars via secrets: to deploy-gateway job', () => {
783
- const DEPLOY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy.yaml')
784
-
785
- for (const secret of OPERATOR_TUNING_SECRETS) {
786
- it(`deploy-gateway job secrets block passes ${secret}`, async () => {
787
- const text = await Bun.file(DEPLOY_WORKFLOW).text()
788
- const parsed = parseYaml(text) as {
789
- jobs?: {'deploy-gateway'?: {secrets?: Record<string, string>}}
790
- }
791
- const secrets = parsed?.jobs?.['deploy-gateway']?.secrets ?? {}
792
- expect(secrets).toHaveProperty(secret)
793
- expect(secrets[secret]).toContain(secret)
794
- })
795
- }
796
-
797
- it('deploy-gateway job does NOT have a with: block for GATEWAY_OPERATOR_OAUTH_ tuning vars', async () => {
798
- const text = await Bun.file(DEPLOY_WORKFLOW).text()
799
- const parsed = parseYaml(text) as {
800
- jobs?: {'deploy-gateway'?: {with?: Record<string, unknown>}}
801
- }
802
- const withBlock = parsed?.jobs?.['deploy-gateway']?.with ?? {}
803
- for (const secret of OPERATOR_TUNING_SECRETS) {
804
- expect(withBlock).not.toHaveProperty(secret)
805
- }
806
- })
807
- })
808
-
809
- describe('CLI getGatewayDeployEnv: operator auth/config passthrough', () => {
810
- it('getGatewayDeployEnv includes all four operator auth/config secret vars', async () => {
811
- const {getGatewayDeployEnv} = await import('./commands/gateway/deploy')
812
- // Provide required env vars
813
- const origEnv = {...process.env}
814
- process.env.PATH = '/usr/bin'
815
- process.env.HOME = '/home/test'
816
- process.env.SSH_AUTH_SOCK = '/tmp/ssh.sock'
817
- try {
818
- const env = getGatewayDeployEnv()
819
- for (const secret of OPERATOR_AUTH_SECRETS) {
820
- expect(env).toHaveProperty(secret)
821
- }
822
- } finally {
823
- Object.assign(process.env, origEnv)
824
- }
825
- })
826
-
827
- it('getGatewayDeployEnv includes all three operator tuning vars', async () => {
828
- const {getGatewayDeployEnv} = await import('./commands/gateway/deploy')
829
- const origEnv = {...process.env}
830
- process.env.PATH = '/usr/bin'
831
- process.env.HOME = '/home/test'
832
- process.env.SSH_AUTH_SOCK = '/tmp/ssh.sock'
833
- try {
834
- const env = getGatewayDeployEnv()
835
- for (const secret of OPERATOR_TUNING_SECRETS) {
836
- expect(env).toHaveProperty(secret)
837
- }
838
- } finally {
839
- Object.assign(process.env, origEnv)
840
- }
841
- })
842
-
843
- it('getGatewayDeployEnv includes GATEWAY_IMAGE_DIGEST', async () => {
844
- const {getGatewayDeployEnv} = await import('./commands/gateway/deploy')
845
- const origEnv = {...process.env}
846
- process.env.PATH = '/usr/bin'
847
- process.env.HOME = '/home/test'
848
- process.env.SSH_AUTH_SOCK = '/tmp/ssh.sock'
849
- process.env.GATEWAY_IMAGE_DIGEST = 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
850
- try {
851
- const env = getGatewayDeployEnv()
852
- expect(env).toHaveProperty('GATEWAY_IMAGE_DIGEST')
853
- expect(env.GATEWAY_IMAGE_DIGEST).toBe('sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')
854
- } finally {
855
- Object.assign(process.env, origEnv)
856
- }
857
- })
858
-
859
- it('getGatewayDeployEnv includes WORKSPACE_IMAGE_DIGEST', async () => {
860
- const {getGatewayDeployEnv} = await import('./commands/gateway/deploy')
861
- const origEnv = {...process.env}
862
- process.env.PATH = '/usr/bin'
863
- process.env.HOME = '/home/test'
864
- process.env.SSH_AUTH_SOCK = '/tmp/ssh.sock'
865
- process.env.WORKSPACE_IMAGE_DIGEST = 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
866
- try {
867
- const env = getGatewayDeployEnv()
868
- expect(env).toHaveProperty('WORKSPACE_IMAGE_DIGEST')
869
- expect(env.WORKSPACE_IMAGE_DIGEST).toBe('sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb')
870
- } finally {
871
- Object.assign(process.env, origEnv)
872
- }
873
- })
874
-
875
- it('getGatewayDeployEnv includes GATEWAY_VPC_IP and DASHBOARD_VPC_IP (but NOT DIGITALOCEAN_ACCESS_TOKEN)', async () => {
876
- const {getGatewayDeployEnv} = await import('./commands/gateway/deploy')
877
- const origEnv = {...process.env}
878
- process.env.PATH = '/usr/bin'
879
- process.env.HOME = '/home/test'
880
- process.env.SSH_AUTH_SOCK = '/tmp/ssh.sock'
881
- try {
882
- const env = getGatewayDeployEnv()
883
- // VPC IPs are still needed by deploy.ts for compose publish + DOCKER-USER rule
884
- expect(env).toHaveProperty('GATEWAY_VPC_IP')
885
- expect(env).toHaveProperty('DASHBOARD_VPC_IP')
886
- // DIGITALOCEAN_ACCESS_TOKEN is provisioning-only — must NOT be in the deploy env
887
- expect(env).not.toHaveProperty('DIGITALOCEAN_ACCESS_TOKEN')
888
- } finally {
889
- Object.assign(process.env, origEnv)
890
- }
891
- })
892
- })
893
-
894
- // ─── deploy.yaml: aggregate router passes operator secrets to deploy-gateway ──
895
-
896
- describe('deploy.yaml: aggregate router forwards operator secrets to deploy-gateway job', () => {
897
- const DEPLOY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy.yaml')
898
-
899
- it('deploy-gateway job secrets block passes GATEWAY_OPERATOR_BIND_HOST', async () => {
900
- const text = await Bun.file(DEPLOY_WORKFLOW).text()
901
- const parsed = parseYaml(text) as {
902
- jobs?: {
903
- 'deploy-gateway'?: {
904
- secrets?: Record<string, string>
905
- }
906
- }
907
- }
908
- const secrets = parsed?.jobs?.['deploy-gateway']?.secrets ?? {}
909
- expect(secrets).toHaveProperty('GATEWAY_OPERATOR_BIND_HOST')
910
- expect(secrets.GATEWAY_OPERATOR_BIND_HOST).toContain('GATEWAY_OPERATOR_BIND_HOST')
911
- })
912
-
913
- it('deploy-gateway job secrets block passes GATEWAY_OPERATOR_BIND_PORT', async () => {
914
- const text = await Bun.file(DEPLOY_WORKFLOW).text()
915
- const parsed = parseYaml(text) as {
916
- jobs?: {
917
- 'deploy-gateway'?: {
918
- secrets?: Record<string, string>
919
- }
920
- }
921
- }
922
- const secrets = parsed?.jobs?.['deploy-gateway']?.secrets ?? {}
923
- expect(secrets).toHaveProperty('GATEWAY_OPERATOR_BIND_PORT')
924
- expect(secrets.GATEWAY_OPERATOR_BIND_PORT).toContain('GATEWAY_OPERATOR_BIND_PORT')
925
- })
926
-
927
- it('deploy-gateway job secrets block passes GATEWAY_OPERATOR_PUBLIC_ORIGIN', async () => {
928
- const text = await Bun.file(DEPLOY_WORKFLOW).text()
929
- const parsed = parseYaml(text) as {
930
- jobs?: {
931
- 'deploy-gateway'?: {
932
- secrets?: Record<string, string>
933
- }
934
- }
935
- }
936
- const secrets = parsed?.jobs?.['deploy-gateway']?.secrets ?? {}
937
- expect(secrets).toHaveProperty('GATEWAY_OPERATOR_PUBLIC_ORIGIN')
938
- expect(secrets.GATEWAY_OPERATOR_PUBLIC_ORIGIN).toContain('GATEWAY_OPERATOR_PUBLIC_ORIGIN')
939
- })
940
- })
941
-
942
- // ─── deploy-gateway.yaml: optional operator secret declarations ───────────────
943
-
944
- describe('deploy-gateway.yaml: optional operator secret declarations (issue 1)', () => {
945
- const DEPLOY_GATEWAY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')
946
-
947
- it('workflow_call.secrets declares GATEWAY_OPERATOR_BIND_HOST as optional', async () => {
948
- const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
949
- const parsed = parseYaml(text) as {on?: {workflow_call?: {secrets?: Record<string, {required?: boolean}>}}}
950
- const secrets = parsed?.on?.workflow_call?.secrets ?? {}
951
- expect(secrets).toHaveProperty('GATEWAY_OPERATOR_BIND_HOST')
952
- expect(secrets.GATEWAY_OPERATOR_BIND_HOST?.required).toBe(false)
953
- })
954
-
955
- it('workflow_call.secrets declares GATEWAY_OPERATOR_BIND_PORT as optional', async () => {
956
- const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
957
- const parsed = parseYaml(text) as {on?: {workflow_call?: {secrets?: Record<string, {required?: boolean}>}}}
958
- const secrets = parsed?.on?.workflow_call?.secrets ?? {}
959
- expect(secrets).toHaveProperty('GATEWAY_OPERATOR_BIND_PORT')
960
- expect(secrets.GATEWAY_OPERATOR_BIND_PORT?.required).toBe(false)
961
- })
962
-
963
- it('workflow_call.secrets declares GATEWAY_OPERATOR_PUBLIC_ORIGIN as optional', async () => {
964
- const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
965
- const parsed = parseYaml(text) as {on?: {workflow_call?: {secrets?: Record<string, {required?: boolean}>}}}
966
- const secrets = parsed?.on?.workflow_call?.secrets ?? {}
967
- expect(secrets).toHaveProperty('GATEWAY_OPERATOR_PUBLIC_ORIGIN')
968
- expect(secrets.GATEWAY_OPERATOR_PUBLIC_ORIGIN?.required).toBe(false)
969
- })
970
-
971
- it('Deploy gateway step env forwards GATEWAY_OPERATOR_BIND_HOST', async () => {
972
- const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
973
- const parsed = parseYaml(text) as {
974
- jobs?: {
975
- 'deploy-gateway'?: {
976
- steps?: {name?: string; env?: Record<string, string>}[]
977
- }
978
- }
979
- }
980
- const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
981
- const deployStep = steps.find(s => s.name === 'Deploy gateway')
982
- expect(deployStep).toBeDefined()
983
- expect(deployStep?.env).toHaveProperty('GATEWAY_OPERATOR_BIND_HOST')
984
- expect(deployStep?.env?.GATEWAY_OPERATOR_BIND_HOST).toContain('GATEWAY_OPERATOR_BIND_HOST')
985
- })
986
-
987
- it('Deploy gateway step env forwards GATEWAY_OPERATOR_BIND_PORT', async () => {
988
- const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
989
- const parsed = parseYaml(text) as {
990
- jobs?: {
991
- 'deploy-gateway'?: {
992
- steps?: {name?: string; env?: Record<string, string>}[]
993
- }
994
- }
995
- }
996
- const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
997
- const deployStep = steps.find(s => s.name === 'Deploy gateway')
998
- expect(deployStep).toBeDefined()
999
- expect(deployStep?.env).toHaveProperty('GATEWAY_OPERATOR_BIND_PORT')
1000
- expect(deployStep?.env?.GATEWAY_OPERATOR_BIND_PORT).toContain('GATEWAY_OPERATOR_BIND_PORT')
1001
- })
1002
-
1003
- it('Deploy gateway step env forwards GATEWAY_OPERATOR_PUBLIC_ORIGIN', async () => {
1004
- const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
1005
- const parsed = parseYaml(text) as {
1006
- jobs?: {
1007
- 'deploy-gateway'?: {
1008
- steps?: {name?: string; env?: Record<string, string>}[]
1009
- }
1010
- }
1011
- }
1012
- const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
1013
- const deployStep = steps.find(s => s.name === 'Deploy gateway')
1014
- expect(deployStep).toBeDefined()
1015
- expect(deployStep?.env).toHaveProperty('GATEWAY_OPERATOR_PUBLIC_ORIGIN')
1016
- expect(deployStep?.env?.GATEWAY_OPERATOR_PUBLIC_ORIGIN).toContain('GATEWAY_OPERATOR_PUBLIC_ORIGIN')
1017
- })
1018
-
1019
- it('operator vars are NOT in the required-secret validation step env', async () => {
1020
- const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
1021
- const parsed = parseYaml(text) as {
1022
- jobs?: {
1023
- 'deploy-gateway'?: {
1024
- steps?: {name?: string; env?: Record<string, string>; run?: string}[]
1025
- }
1026
- }
1027
- }
1028
- const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
1029
- const validateStep = steps.find(s => s.name === 'Validate required secrets')
1030
- expect(validateStep).toBeDefined()
1031
- // Operator vars must NOT be in the validation step env (deploy script enforces all-or-none)
1032
- expect(validateStep?.env ?? {}).not.toHaveProperty('GATEWAY_OPERATOR_BIND_HOST')
1033
- expect(validateStep?.env ?? {}).not.toHaveProperty('GATEWAY_OPERATOR_BIND_PORT')
1034
- expect(validateStep?.env ?? {}).not.toHaveProperty('GATEWAY_OPERATOR_PUBLIC_ORIGIN')
1035
- })
1036
- })
1037
-
1038
- // ─── VPC bridge secrets: CI-vs-local parity ──────────────────────────────────
1039
- //
1040
- // GATEWAY_VPC_IP and DASHBOARD_VPC_IP are optional (all-or-none via getOperatorVpcState)
1041
- // but must be forwarded through CI so the operator VPC bridge is not silently disabled
1042
- // when the secrets are set. DIGITALOCEAN_ACCESS_TOKEN is provisioning-only and must NOT
1043
- // be forwarded to the deploy step.
1044
- // These tests assert the end-to-end wiring without adding them to the required
1045
- // validation preflight (they are opt-in, like the operator listener vars).
1046
-
1047
- const VPC_GATEWAY_SECRETS = ['GATEWAY_VPC_IP', 'DASHBOARD_VPC_IP'] as const
1048
- const VPC_DASHBOARD_SECRETS = ['GATEWAY_VPC_IP'] as const
1049
-
1050
- describe('deploy-gateway.yaml: VPC bridge secrets wired end-to-end', () => {
1051
- const DEPLOY_GATEWAY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')
1052
-
1053
- for (const secret of VPC_GATEWAY_SECRETS) {
1054
- it(`workflow_call.secrets declares ${secret} as optional (required: false)`, async () => {
1055
- const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
1056
- const parsed = parseYaml(text) as {on?: {workflow_call?: {secrets?: Record<string, {required?: boolean}>}}}
1057
- const secrets = parsed?.on?.workflow_call?.secrets ?? {}
1058
- expect(secrets).toHaveProperty(secret)
1059
- expect(secrets[secret]?.required).toBe(false)
1060
- })
1061
-
1062
- it(`Deploy gateway step env forwards ${secret} from secrets context`, async () => {
1063
- const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
1064
- const parsed = parseYaml(text) as {
1065
- jobs?: {'deploy-gateway'?: {steps?: {name?: string; env?: Record<string, string>}[]}}
1066
- }
1067
- const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
1068
- const deployStep = steps.find(s => s.name === 'Deploy gateway')
1069
- expect(deployStep).toBeDefined()
1070
- expect(deployStep?.env).toHaveProperty(secret)
1071
- expect(deployStep?.env?.[secret]).toMatch(/\$\{\{\s*secrets\./)
1072
- })
1073
- }
1074
-
1075
- it('VPC bridge secrets are NOT in the required-secret validation step env', async () => {
1076
- const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
1077
- const parsed = parseYaml(text) as {
1078
- jobs?: {'deploy-gateway'?: {steps?: {name?: string; env?: Record<string, string>; run?: string}[]}}
1079
- }
1080
- const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
1081
- const validateStep = steps.find(s => s.name === 'Validate required secrets')
1082
- expect(validateStep).toBeDefined()
1083
- for (const secret of VPC_GATEWAY_SECRETS) {
1084
- expect(validateStep?.env ?? {}).not.toHaveProperty(secret)
1085
- }
1086
- })
1087
- })
1088
-
1089
- describe('deploy-dashboard.yaml: VPC bridge secret wired end-to-end', () => {
1090
- const DEPLOY_DASHBOARD_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy-dashboard.yaml')
1091
-
1092
- for (const secret of VPC_DASHBOARD_SECRETS) {
1093
- it(`workflow_call.secrets declares ${secret} as optional (required: false)`, async () => {
1094
- const text = await Bun.file(DEPLOY_DASHBOARD_WORKFLOW).text()
1095
- const parsed = parseYaml(text) as {on?: {workflow_call?: {secrets?: Record<string, {required?: boolean}>}}}
1096
- const secrets = parsed?.on?.workflow_call?.secrets ?? {}
1097
- expect(secrets).toHaveProperty(secret)
1098
- expect(secrets[secret]?.required).toBe(false)
1099
- })
1100
-
1101
- it(`Deploy step env forwards ${secret} from secrets context`, async () => {
1102
- const text = await Bun.file(DEPLOY_DASHBOARD_WORKFLOW).text()
1103
- const parsed = parseYaml(text) as {
1104
- jobs?: {'deploy-dashboard'?: {steps?: {name?: string; env?: Record<string, string>}[]}}
1105
- }
1106
- const steps = parsed?.jobs?.['deploy-dashboard']?.steps ?? []
1107
- const deployStep = steps.find(s => s.name === 'Deploy')
1108
- expect(deployStep).toBeDefined()
1109
- expect(deployStep?.env).toHaveProperty(secret)
1110
- expect(deployStep?.env?.[secret]).toMatch(/\$\{\{\s*secrets\./)
1111
- })
1112
- }
1113
-
1114
- it('GATEWAY_VPC_IP is NOT in the required-secret validation step env', async () => {
1115
- const text = await Bun.file(DEPLOY_DASHBOARD_WORKFLOW).text()
1116
- const parsed = parseYaml(text) as {
1117
- jobs?: {'deploy-dashboard'?: {steps?: {name?: string; env?: Record<string, string>; run?: string}[]}}
1118
- }
1119
- const steps = parsed?.jobs?.['deploy-dashboard']?.steps ?? []
1120
- const validateStep = steps.find(s => s.name === 'Validate required secrets')
1121
- expect(validateStep).toBeDefined()
1122
- expect(validateStep?.env ?? {}).not.toHaveProperty('GATEWAY_VPC_IP')
1123
- })
1124
- })
1125
-
1126
- describe('deploy.yaml: aggregate router forwards VPC bridge secrets', () => {
1127
- const DEPLOY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy.yaml')
1128
-
1129
- for (const secret of VPC_GATEWAY_SECRETS) {
1130
- it(`deploy-gateway job secrets block passes ${secret}`, async () => {
1131
- const text = await Bun.file(DEPLOY_WORKFLOW).text()
1132
- const parsed = parseYaml(text) as {
1133
- jobs?: {'deploy-gateway'?: {secrets?: Record<string, string>}}
1134
- }
1135
- const secrets = parsed?.jobs?.['deploy-gateway']?.secrets ?? {}
1136
- expect(secrets).toHaveProperty(secret)
1137
- expect(secrets[secret]).toContain(secret)
1138
- })
1139
- }
1140
-
1141
- for (const secret of VPC_DASHBOARD_SECRETS) {
1142
- it(`deploy-dashboard job secrets block passes ${secret}`, async () => {
1143
- const text = await Bun.file(DEPLOY_WORKFLOW).text()
1144
- const parsed = parseYaml(text) as {
1145
- jobs?: {'deploy-dashboard'?: {secrets?: Record<string, string>}}
1146
- }
1147
- const secrets = parsed?.jobs?.['deploy-dashboard']?.secrets ?? {}
1148
- expect(secrets).toHaveProperty(secret)
1149
- expect(secrets[secret]).toContain(secret)
1150
- })
1151
- }
1152
- })
1153
-
1154
- // ─── deploy.yaml: deploy-dashboard is decoupled from deploy-gateway ──────────
1155
- //
1156
- // The dashboard is a standalone deployment. A gateway failure or slowness must
1157
- // not block dashboard deploys. deploy-dashboard must NOT declare needs: deploy-gateway,
1158
- // and its if: must NOT reference needs.deploy-gateway — locking in the decoupling
1159
- // so it cannot silently regress.
1160
-
1161
- describe('deploy.yaml: deploy-dashboard is decoupled from deploy-gateway', () => {
1162
- const DEPLOY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy.yaml')
1163
-
1164
- it('deploy-dashboard needs: does NOT include deploy-gateway (standalone job)', async () => {
1165
- const text = await Bun.file(DEPLOY_WORKFLOW).text()
1166
- const parsed = parseYaml(text) as {
1167
- jobs?: {'deploy-dashboard'?: {needs?: string | string[]}}
1168
- }
1169
- const needs = parsed?.jobs?.['deploy-dashboard']?.needs ?? []
1170
- const needsArr = Array.isArray(needs) ? needs : [needs]
1171
- expect(needsArr).not.toContain('deploy-gateway')
1172
- })
1173
-
1174
- it('deploy-dashboard if: does NOT reference needs.deploy-gateway (no gateway coupling)', async () => {
1175
- const text = await Bun.file(DEPLOY_WORKFLOW).text()
1176
- // The if: expression must not reference needs.deploy-gateway in any form —
1177
- // gateway state must not gate the dashboard deploy.
1178
- expect(text).not.toMatch(/needs\.deploy-gateway/)
1179
- })
1180
- })