boxdown 1.0.0 → 1.2.1

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 (76) hide show
  1. package/README.md +135 -12
  2. package/assets/devcontainer/README.md +65 -21
  3. package/assets/devcontainer/devcontainer.json +27 -15
  4. package/assets/devcontainer/hooks/initialize.sh +76 -22
  5. package/assets/devcontainer/hooks/post-create.sh +70 -12
  6. package/assets/devcontainer/hooks/post-start.sh +20 -13
  7. package/assets/devcontainer/ssh-config-install.sh +12 -3
  8. package/assets/devcontainer/start.sh +721 -44
  9. package/assets/devcontainer/utils/coding-agent-cli-update.sh +267 -3
  10. package/assets/devcontainer/utils/deps-install.sh +68 -0
  11. package/assets/devcontainer/utils/git-config-bootstrap.sh +128 -0
  12. package/assets/devcontainer/utils/git-signing-bootstrap.sh +109 -0
  13. package/assets/devcontainer/utils/python-bootstrap.sh +69 -0
  14. package/assets/devcontainer/utils/secret-env-bootstrap.sh +22 -0
  15. package/assets/devcontainer/utils/ssh-agent-proxy-bootstrap.sh +27 -0
  16. package/assets/devcontainer/utils/ssh-agent-proxy.mjs +39 -0
  17. package/assets/devcontainer/utils/ssh-bootstrap.sh +9 -0
  18. package/dist/bin/cli.cjs +1 -1
  19. package/dist/bin/cli.mjs +1 -1
  20. package/dist/main-BDgyf2t5.cjs +5758 -0
  21. package/dist/main-J4_2Up3o.mjs +5718 -0
  22. package/dist/main-J4_2Up3o.mjs.map +1 -0
  23. package/dist/main.cjs +5 -2
  24. package/dist/main.d.cts +501 -4
  25. package/dist/main.d.cts.map +1 -1
  26. package/dist/main.d.mts +501 -4
  27. package/dist/main.d.mts.map +1 -1
  28. package/dist/main.mjs +2 -2
  29. package/docs/README.md +1 -0
  30. package/docs/architecture.md +32 -0
  31. package/docs/development.md +13 -6
  32. package/docs/features/README.md +2 -0
  33. package/docs/features/commit-signing.md +94 -0
  34. package/docs/features/generated-config-and-state.md +73 -5
  35. package/docs/features/github-auth-refresh.md +15 -2
  36. package/docs/features/lifecycle.md +103 -11
  37. package/docs/features/setup.md +66 -0
  38. package/docs/features/ssh-config-and-proxy.md +228 -7
  39. package/docs/features/start-and-shell.md +45 -5
  40. package/docs/superpowers/plans/2026-07-11-default-commit-signing.md +110 -0
  41. package/docs/superpowers/plans/2026-07-11-progress-ci-regression.md +125 -0
  42. package/docs/superpowers/plans/2026-07-17-node-ssh-agent-proxy-signing.md +132 -0
  43. package/docs/superpowers/plans/2026-07-17-runtime-secret-environment.md +174 -0
  44. package/docs/superpowers/specs/2026-07-11-default-commit-signing-design.md +416 -0
  45. package/docs/superpowers/specs/2026-07-11-progress-ci-regression-design.md +43 -0
  46. package/docs/superpowers/specs/2026-07-17-node-ssh-agent-proxy-design.md +63 -0
  47. package/docs/superpowers/specs/2026-07-17-runtime-secret-environment-design.md +194 -0
  48. package/docs/testing.md +35 -2
  49. package/docs/todo.md +2 -2
  50. package/package.json +1 -1
  51. package/src/claude-app-config.ts +304 -0
  52. package/src/cli-style.ts +43 -0
  53. package/src/codex-app-config.ts +656 -0
  54. package/src/config.ts +80 -10
  55. package/src/constants.ts +12 -0
  56. package/src/devcontainer.ts +511 -64
  57. package/src/doctor.ts +292 -30
  58. package/src/git-signing.ts +267 -0
  59. package/src/interactive-prompts.ts +692 -0
  60. package/src/list.ts +16 -2
  61. package/src/logging.ts +164 -0
  62. package/src/main.ts +1214 -63
  63. package/src/metadata.ts +52 -3
  64. package/src/package-info.ts +18 -0
  65. package/src/paths.ts +71 -11
  66. package/src/process.ts +80 -2
  67. package/src/progress.ts +593 -0
  68. package/src/purge.ts +216 -0
  69. package/src/shell.ts +25 -0
  70. package/src/ssh-config.ts +134 -16
  71. package/src/ssh-install-targets.ts +111 -0
  72. package/src/ssh-key.ts +53 -13
  73. package/src/status.ts +5 -0
  74. package/dist/main-BuEptwlL.cjs +0 -1707
  75. package/dist/main-ZFTrSVgt.mjs +0 -1685
  76. package/dist/main-ZFTrSVgt.mjs.map +0 -1
package/src/doctor.ts CHANGED
@@ -1,6 +1,10 @@
1
- import { existsSync } from 'node:fs'
1
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
2
3
 
3
4
  import { resolveDevcontainerCli } from './devcontainer-cli.ts'
5
+ import { buildGeneratedDevcontainerConfig, type DevcontainerConfig } from './config.ts'
6
+ import { BOXDOWN_SECRET_ENV_NAMES } from './constants.ts'
7
+ import { resolveConfiguredSshSigningKey, selectGitSigningKey, type GitSigningReason } from './git-signing.ts'
4
8
  import type { WorkspaceContext } from './paths.ts'
5
9
  import { runBuffered } from './process.ts'
6
10
 
@@ -12,6 +16,20 @@ export interface DoctorCheck {
12
16
  message: string
13
17
  }
14
18
 
19
+ export interface DoctorCommandResult {
20
+ code: number
21
+ stdout: string
22
+ stderr: string
23
+ }
24
+
25
+ export type DoctorCommandRunner = (command: string, args: string[]) => Promise<DoctorCommandResult>
26
+
27
+ export interface RunDoctorChecksOptions {
28
+ includeOptional?: boolean
29
+ includeDockerMountProbe?: boolean
30
+ runCommand?: DoctorCommandRunner
31
+ }
32
+
15
33
  function nodeVersionPasses (version: string): boolean {
16
34
  const major = Number.parseInt(version.split('.')[0] ?? '', 10)
17
35
  return Number.isInteger(major) && major >= 24
@@ -25,26 +43,56 @@ function check (name: string, pass: boolean, okMessage: string, failMessage: str
25
43
  }
26
44
  }
27
45
 
28
- async function commandWorks (command: string, args: string[]): Promise<boolean> {
29
- const result = await runBuffered(command, args, {
30
- mirrorStdout: false,
31
- mirrorStderr: false
32
- })
46
+ function secretEnvironmentConfigCheck (context: WorkspaceContext): DoctorCheck {
47
+ let config: DevcontainerConfig
33
48
 
34
- return result.code === 0
49
+ try {
50
+ config = existsSync(context.generatedConfigPath)
51
+ ? JSON.parse(readFileSync(context.generatedConfigPath, 'utf8')) as DevcontainerConfig
52
+ : buildGeneratedDevcontainerConfig(context)
53
+ } catch {
54
+ return {
55
+ name: 'secret-environment-config',
56
+ level: 'warn',
57
+ message: 'Generated config could not be checked for secret-safe environment handling'
58
+ }
59
+ }
60
+
61
+ const runArgs = Array.isArray(config.runArgs) ? config.runArgs : []
62
+ const containerEnv = config.containerEnv ?? {}
63
+ const unsafe = runArgs.includes('--env-file') ||
64
+ runArgs.some((arg) => arg.includes('.env.development')) ||
65
+ BOXDOWN_SECRET_ENV_NAMES.some((name) => Object.hasOwn(containerEnv, name))
66
+
67
+ return {
68
+ name: 'secret-environment-config',
69
+ level: unsafe ? 'warn' : 'ok',
70
+ message: unsafe
71
+ ? 'Generated config still exposes Boxdown secrets through Docker environment settings; recreate after upgrading Boxdown'
72
+ : 'Generated config uses runtime-mounted secrets without Docker environment values'
73
+ }
35
74
  }
36
75
 
37
- async function commandExists (command: string, args: string[]): Promise<boolean> {
38
- const result = await runBuffered(command, args, {
76
+ async function runDoctorCommand (command: string, args: string[]): Promise<DoctorCommandResult> {
77
+ return runBuffered(command, args, {
39
78
  mirrorStdout: false,
40
79
  mirrorStderr: false
41
80
  })
81
+ }
82
+
83
+ async function commandWorks (runCommand: DoctorCommandRunner, command: string, args: string[]): Promise<boolean> {
84
+ const result = await runCommand(command, args)
85
+ return result.code === 0
86
+ }
42
87
 
88
+ async function commandExists (runCommand: DoctorCommandRunner, command: string, args: string[]): Promise<boolean> {
89
+ const result = await runCommand(command, args)
43
90
  return result.code !== 127
44
91
  }
45
92
 
46
- export async function runDoctorChecks (context: WorkspaceContext): Promise<DoctorCheck[]> {
93
+ export async function runDoctorChecks (context: WorkspaceContext, options: RunDoctorChecksOptions = {}): Promise<DoctorCheck[]> {
47
94
  const checks: DoctorCheck[] = []
95
+ const runCommand = options.runCommand ?? runDoctorCommand
48
96
  const nodeVersion = process.versions.node
49
97
 
50
98
  checks.push(check(
@@ -54,37 +102,117 @@ export async function runDoctorChecks (context: WorkspaceContext): Promise<Docto
54
102
  `Node ${nodeVersion}; expected >=24.0.0`
55
103
  ))
56
104
 
105
+ const sshAgent = await runCommand('ssh-add', ['-L'])
106
+ const identityLines = sshAgent.code === 0
107
+ ? sshAgent.stdout.split(/\r?\n/).filter((line) => line.trim().startsWith('ssh-'))
108
+ : []
109
+ const identities = identityLines.length
110
+ let configuredKey: string | undefined
111
+ let configuredFailure: { reason: GitSigningReason, detail?: string } | undefined
112
+ const format = await runCommand('git', ['config', '--global', '--get', 'gpg.format'])
113
+ if (format.code === 0 && format.stdout.trim() === 'ssh') {
114
+ const signingKey = await runCommand('git', ['config', '--global', '--get', 'user.signingkey'])
115
+ if (signingKey.code === 0 && signingKey.stdout.trim().length > 0) {
116
+ const resolved = resolveConfiguredSshSigningKey(signingKey.stdout.trim(), {
117
+ homeDir: dirname(context.hostGitconfigPath),
118
+ workspaceFolder: context.workspaceFolder
119
+ })
120
+ if (resolved.key === undefined) {
121
+ configuredFailure = {
122
+ reason: resolved.reason ?? 'configured-key-invalid',
123
+ detail: resolved.detail
124
+ }
125
+ } else {
126
+ configuredKey = resolved.key
127
+ }
128
+ }
129
+ }
130
+
131
+ const includeOptional = options.includeOptional ?? true
132
+ let ghAvailable = false
133
+ let ghAuth = false
134
+ let githubLogin: string | undefined
135
+ let githubAuthKeys: string[] | undefined
136
+ if (includeOptional) {
137
+ ghAvailable = await commandWorks(runCommand, 'gh', ['--version'])
138
+ if (ghAvailable) {
139
+ ghAuth = await commandWorks(runCommand, 'gh', ['auth', 'status', '--hostname', 'github.com'])
140
+ if (ghAuth && configuredKey === undefined && configuredFailure === undefined && identities > 1) {
141
+ const user = await runCommand('gh', ['api', 'user', '--jq', '.login'])
142
+ githubLogin = user.code === 0 && user.stdout.trim().length > 0 ? user.stdout.trim() : undefined
143
+ if (githubLogin !== undefined) {
144
+ const authentication = await runCommand('gh', ['api', `users/${githubLogin}/keys`, '--paginate', '--jq', '.[].key'])
145
+ if (authentication.code === 0) githubAuthKeys = authentication.stdout.split(/\r?\n/)
146
+ }
147
+ }
148
+ }
149
+ }
150
+
151
+ const selected: { key?: string, reason?: GitSigningReason } = configuredFailure ?? selectGitSigningKey(identityLines, configuredKey, githubAuthKeys)
152
+ const selectedByConfiguration = selected.key !== undefined && configuredKey !== undefined
153
+ const selectedByGithub = selected.key !== undefined && configuredKey === undefined && identities > 1
154
+ const signingMessages: Record<GitSigningReason, string> = {
155
+ 'agent-unavailable': 'SSH agent is unavailable; Boxdown commits will remain unsigned',
156
+ 'no-identities': 'SSH agent has no identities; Boxdown commits will remain unsigned',
157
+ 'ambiguous-identities': 'SSH agent has multiple identities; Boxdown will not guess a signing key and commits will remain unsigned',
158
+ 'configured-key-unreadable': 'Configured SSH signing-key file could not be read; Boxdown commits will remain unsigned',
159
+ 'configured-key-invalid': 'Configured SSH signing key is not a valid public key; Boxdown commits will remain unsigned',
160
+ 'configured-key-not-loaded': 'Configured SSH signing key is not loaded in the agent; Boxdown commits will remain unsigned',
161
+ 'agent-socket-unavailable': 'Host SSH-agent socket is unavailable; Boxdown commits will remain unsigned',
162
+ 'docker-probe-image-unavailable': 'No local Docker image is available to probe commit-signing agent forwarding',
163
+ 'agent-mount-unavailable': 'Docker could not mount the host SSH-agent socket; Boxdown commits will remain unsigned'
164
+ }
165
+ checks.push({
166
+ name: 'git-signing-agent',
167
+ level: sshAgent.code === 0 && selected.key !== undefined ? 'ok' : 'warn',
168
+ message: sshAgent.code !== 0
169
+ ? signingMessages['agent-unavailable']
170
+ : selected.key === undefined
171
+ ? signingMessages[selected.reason ?? 'ambiguous-identities']
172
+ : selectedByConfiguration
173
+ ? 'Configured SSH signing key is loaded in the agent'
174
+ : selectedByGithub
175
+ ? 'GitHub authentication keys identify one SSH agent identity for Boxdown commit signing'
176
+ : 'One SSH agent identity is available for Boxdown commit signing'
177
+ })
178
+
57
179
  checks.push(check(
58
180
  'devcontainers-cli',
59
- await packagedDevcontainerCliWorks(context),
181
+ await packagedDevcontainerCliWorks(context, runCommand),
60
182
  'Packaged @devcontainers/cli is available',
61
183
  'Packaged @devcontainers/cli is required but was not available'
62
184
  ))
63
185
 
186
+ const dockerCliWorks = await commandWorks(runCommand, 'docker', ['--version'])
64
187
  checks.push(check(
65
188
  'docker-cli',
66
- await commandWorks('docker', ['--version']),
189
+ dockerCliWorks,
67
190
  'Docker CLI is available',
68
191
  'Docker CLI is required but was not available'
69
192
  ))
70
193
 
194
+ const dockerDaemonWorks = await commandWorks(runCommand, 'docker', ['info'])
71
195
  checks.push(check(
72
196
  'docker-daemon',
73
- await commandWorks('docker', ['info']),
197
+ dockerDaemonWorks,
74
198
  'Docker daemon is reachable',
75
199
  'Docker daemon is required but was not reachable'
76
200
  ))
77
201
 
78
202
  checks.push(check(
79
203
  'ssh',
80
- await commandExists('ssh', ['-V']),
204
+ await commandExists(runCommand, 'ssh', ['-V']),
81
205
  'ssh is available',
82
206
  'ssh is required but was not available'
83
207
  ))
84
208
 
209
+ if (options.includeDockerMountProbe ?? true) {
210
+ checks.push(await checkDockerBindMounts(context, runCommand, dockerCliWorks && dockerDaemonWorks))
211
+ }
212
+
85
213
  checks.push(check(
86
214
  'ssh-keygen',
87
- await commandExists('ssh-keygen', ['-?']),
215
+ await commandExists(runCommand, 'ssh-keygen', ['-?']),
88
216
  'ssh-keygen is available',
89
217
  'ssh-keygen is required but was not available'
90
218
  ))
@@ -96,28 +224,162 @@ export async function runDoctorChecks (context: WorkspaceContext): Promise<Docto
96
224
  `Missing Boxdown devcontainer assets: ${context.assetsDevcontainerDir}`
97
225
  ))
98
226
 
99
- if (await commandWorks('gh', ['--version'])) {
100
- const ghAuth = await commandWorks('gh', ['auth', 'status', '--hostname', 'github.com'])
101
- checks.push({
102
- name: 'gh-auth',
103
- level: ghAuth ? 'ok' : 'warn',
104
- message: ghAuth ? 'GitHub CLI auth is available' : 'GitHub CLI is available but not authenticated'
105
- })
106
- } else {
107
- checks.push({
108
- name: 'gh',
109
- level: 'warn',
110
- message: 'GitHub CLI is optional and was not available'
111
- })
227
+ checks.push(secretEnvironmentConfigCheck(context))
228
+
229
+ if (includeOptional) {
230
+ if (ghAvailable) {
231
+ checks.push({
232
+ name: 'gh-auth',
233
+ level: ghAuth ? 'ok' : 'warn',
234
+ message: ghAuth ? 'GitHub CLI auth is available' : 'GitHub CLI is available but not authenticated'
235
+ })
236
+ if (ghAuth && selected.key !== undefined) {
237
+ if (githubLogin === undefined) {
238
+ const user = await runCommand('gh', ['api', 'user', '--jq', '.login'])
239
+ githubLogin = user.code === 0 && user.stdout.trim().length > 0 ? user.stdout.trim() : undefined
240
+ }
241
+ const signing = githubLogin !== undefined
242
+ ? await runCommand('gh', ['api', `users/${githubLogin}/ssh_signing_keys`, '--paginate', '--jq', '.[].key'])
243
+ : { code: 1, stdout: '', stderr: '' }
244
+ checks.push({
245
+ name: 'git-signing-github',
246
+ level: signing.code === 0 && signing.stdout.includes(selected.key) ? 'ok' : 'warn',
247
+ message: signing.code !== 0
248
+ ? 'GitHub SSH signing-key registration could not be checked'
249
+ : signing.stdout.includes(selected.key)
250
+ ? 'Selected SSH key is registered with GitHub for commit signing'
251
+ : 'Register the selected public key with GitHub as a signing key to receive Verified badges'
252
+ })
253
+ }
254
+ } else {
255
+ checks.push({
256
+ name: 'gh',
257
+ level: 'warn',
258
+ message: 'GitHub CLI is optional and was not available'
259
+ })
260
+ }
112
261
  }
113
262
 
114
263
  return checks
115
264
  }
116
265
 
117
- async function packagedDevcontainerCliWorks (context: WorkspaceContext): Promise<boolean> {
266
+ function dockerProbeImage (output: string): string | undefined {
267
+ return output
268
+ .split(/\r?\n/)
269
+ .map((line) => line.trim())
270
+ .find((image) => image.length > 0 && image !== '<none>:<none>')
271
+ }
272
+
273
+ function dockerMountError (output: string): boolean {
274
+ return /invalid mount config|bind source path does not exist|mount denied|file sharing|mounts denied|permission denied|operation not permitted/i.test(output)
275
+ }
276
+
277
+ function compactOutput (output: string): string {
278
+ return output.trim().replace(/\s+/g, ' ').slice(0, 300)
279
+ }
280
+
281
+ interface DockerMountSource {
282
+ label: string
283
+ path: string
284
+ }
285
+
286
+ async function checkDockerBindMounts (
287
+ context: WorkspaceContext,
288
+ runCommand: DoctorCommandRunner,
289
+ dockerReady: boolean
290
+ ): Promise<DoctorCheck> {
291
+ if (!dockerReady) {
292
+ return {
293
+ name: 'docker-bind-mounts',
294
+ level: 'warn',
295
+ message: 'Docker bind-mount readiness was not checked because Docker is unavailable'
296
+ }
297
+ }
298
+
299
+ const imageResult = await runCommand('docker', ['image', 'ls', '--format', '{{.Repository}}:{{.Tag}}'])
300
+ const image = imageResult.code === 0 ? dockerProbeImage(imageResult.stdout) : undefined
301
+
302
+ if (image === undefined) {
303
+ return {
304
+ name: 'docker-bind-mounts',
305
+ level: 'warn',
306
+ message: 'Docker bind-mount readiness was not checked because no local Docker image is available'
307
+ }
308
+ }
309
+
310
+ mkdirSync(context.workspaceDataDir, { recursive: true })
311
+ const runtimeProbeDir = mkdtempSync(join(context.workspaceDataDir, 'doctor-mount-probe-'))
312
+ mkdirSync(context.workspaceSecretEnvDir, { recursive: true, mode: 0o700 })
313
+ chmodSync(context.workspaceSecretEnvDir, 0o700)
314
+ const sources: DockerMountSource[] = [
315
+ { label: 'workspace', path: context.workspaceFolder },
316
+ { label: 'Boxdown devcontainer assets', path: context.assetsDevcontainerDir },
317
+ { label: 'Boxdown runtime state', path: runtimeProbeDir },
318
+ { label: 'Boxdown runtime secret state', path: context.workspaceSecretEnvDir }
319
+ ]
320
+
321
+ try {
322
+ for (const source of sources) {
323
+ const createResult = await runCommand('docker', [
324
+ 'create',
325
+ '--pull=never',
326
+ '--entrypoint',
327
+ '/bin/true',
328
+ '--mount',
329
+ `type=bind,source=${source.path},target=/boxdown-preflight,readonly`,
330
+ image
331
+ ])
332
+ const output = `${createResult.stderr}\n${createResult.stdout}`
333
+
334
+ if (createResult.code !== 0) {
335
+ if (dockerMountError(output)) {
336
+ return {
337
+ name: 'docker-bind-mounts',
338
+ level: 'fail',
339
+ message: `Docker cannot bind-mount the ${source.label} path (${source.path}). Check Docker Desktop file sharing and host-folder permissions.`
340
+ }
341
+ }
342
+
343
+ return {
344
+ name: 'docker-bind-mounts',
345
+ level: 'warn',
346
+ message: `Docker bind-mount readiness could not be checked for ${source.label}: ${compactOutput(output) || 'Docker create failed'}`
347
+ }
348
+ }
349
+
350
+ const containerId = createResult.stdout.trim().split(/\r?\n/)[0]
351
+ if (containerId === undefined || containerId.length === 0) {
352
+ return {
353
+ name: 'docker-bind-mounts',
354
+ level: 'warn',
355
+ message: `Docker bind-mount readiness could not be checked for ${source.label}: Docker did not return a container ID`
356
+ }
357
+ }
358
+
359
+ const removeResult = await runCommand('docker', ['rm', '-f', containerId])
360
+ if (removeResult.code !== 0) {
361
+ return {
362
+ name: 'docker-bind-mounts',
363
+ level: 'warn',
364
+ message: `Docker bind-mount readiness was checked, but the disposable probe container could not be removed: ${compactOutput(removeResult.stderr) || 'docker rm failed'}`
365
+ }
366
+ }
367
+ }
368
+ } finally {
369
+ rmSync(runtimeProbeDir, { recursive: true, force: true })
370
+ }
371
+
372
+ return {
373
+ name: 'docker-bind-mounts',
374
+ level: 'ok',
375
+ message: 'Docker can bind-mount Boxdown workspace, assets, and runtime-state paths'
376
+ }
377
+ }
378
+
379
+ async function packagedDevcontainerCliWorks (context: WorkspaceContext, runCommand: DoctorCommandRunner): Promise<boolean> {
118
380
  try {
119
381
  const cli = resolveDevcontainerCli(context)
120
- return await commandWorks(cli.command, [...cli.argsPrefix, '--version'])
382
+ return await commandWorks(runCommand, cli.command, [...cli.argsPrefix, '--version'])
121
383
  } catch {
122
384
  return false
123
385
  }
@@ -0,0 +1,267 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { isAbsolute, join, resolve } from 'node:path'
3
+
4
+ import type { WorkspaceCommandLogger } from './logging.ts'
5
+ import type { WorkspaceContext } from './paths.ts'
6
+ import { runBuffered, type CommandResult } from './process.ts'
7
+
8
+ export type GitSigningReason = 'agent-unavailable' | 'no-identities' | 'ambiguous-identities' | 'configured-key-unreadable' | 'configured-key-invalid' | 'configured-key-not-loaded' | 'agent-socket-unavailable' | 'docker-probe-image-unavailable' | 'agent-mount-unavailable'
9
+
10
+ export interface GitSigningPlan {
11
+ enabled: boolean
12
+ reason?: GitSigningReason
13
+ detail?: string
14
+ publicKey?: string
15
+ agentSocketSource?: string
16
+ }
17
+
18
+ const GIT_SIGNING_REASON_MESSAGES: Record<GitSigningReason, string> = {
19
+ 'agent-unavailable': 'the host SSH agent is unavailable',
20
+ 'no-identities': 'the host SSH agent has no loaded identities',
21
+ 'ambiguous-identities': 'multiple SSH identities are loaded and no signing key could be selected safely',
22
+ 'configured-key-unreadable': 'the configured SSH signing-key file could not be read',
23
+ 'configured-key-invalid': 'the configured SSH signing key is not a valid public key',
24
+ 'configured-key-not-loaded': 'the configured SSH signing key is not loaded in the agent',
25
+ 'agent-socket-unavailable': 'the host SSH-agent socket is unavailable',
26
+ 'docker-probe-image-unavailable': 'no local Docker image is available to probe the SSH-agent mount',
27
+ 'agent-mount-unavailable': 'Docker could not mount the host SSH-agent socket'
28
+ }
29
+
30
+ function compactDiagnosticDetail (detail: string): string {
31
+ return detail
32
+ .trim()
33
+ .replace(/\s+/gu, ' ')
34
+ .slice(0, 2000)
35
+ .replace(/-----BEGIN [^-]*PRIVATE KEY-----.*?(?:-----END [^-]*PRIVATE KEY-----|$)/giu, '[redacted-private-key]')
36
+ .replace(/\b(?:ssh-[A-Za-z0-9-]+|ecdsa-sha2-[A-Za-z0-9-]+|sk-(?:ssh-[A-Za-z0-9-]+|ecdsa-sha2-[A-Za-z0-9-]+))\s+[A-Za-z0-9+/=]{16,}/gu, '[redacted-ssh-key]')
37
+ .replace(/\b(?:github_pat_|gh[pousr]_|glpat-|xox[baprs]-)[A-Za-z0-9_-]{8,}/gu, '[redacted-token]')
38
+ .replace(/\b(?:Bearer\s+|token[=:]\s*)[A-Za-z0-9._~+/-]{8,}/giu, '[redacted-token]')
39
+ .slice(0, 300)
40
+ }
41
+
42
+ export function reportGitSigningPlan (
43
+ plan: GitSigningPlan,
44
+ options: {
45
+ logger?: Pick<WorkspaceCommandLogger, 'boxdown'>
46
+ quiet?: boolean
47
+ writeWarning?: (message: string) => void
48
+ } = {}
49
+ ): void {
50
+ if (plan.enabled) return
51
+
52
+ const reason = plan.reason ?? 'agent-unavailable'
53
+ const detail = plan.detail === undefined ? '' : ` detail=${compactDiagnosticDetail(plan.detail)}`
54
+ options.logger?.boxdown(`git-signing: enabled=false reason=${reason}${detail}\n`)
55
+
56
+ if (options.quiet !== true) {
57
+ const writeWarning = options.writeWarning ?? ((message: string) => process.stderr.write(message))
58
+ writeWarning(`boxdown: commit signing disabled: ${GIT_SIGNING_REASON_MESSAGES[reason]}; commits will remain unsigned.\n`)
59
+ }
60
+ }
61
+
62
+ export function parseSshPublicKey (value: string): string | undefined {
63
+ const [algorithm, key] = value.trim().split(/\s+/, 3)
64
+ if (algorithm === undefined || key === undefined || !/^ssh-[A-Za-z0-9-]+$/.test(algorithm) || key.length === 0) {
65
+ return undefined
66
+ }
67
+
68
+ return `${algorithm} ${key}`
69
+ }
70
+
71
+ export interface ConfiguredSshSigningKeyResult {
72
+ key?: string
73
+ reason?: Extract<GitSigningReason, 'configured-key-unreadable' | 'configured-key-invalid'>
74
+ detail?: string
75
+ }
76
+
77
+ export function resolveConfiguredSshSigningKey (
78
+ value: string,
79
+ options: { homeDir?: string, workspaceFolder: string }
80
+ ): ConfiguredSshSigningKeyResult {
81
+ const inlineValue = value.startsWith('key::') ? value.slice('key::'.length) : value
82
+ const inlineKey = parseSshPublicKey(inlineValue)
83
+ if (inlineKey !== undefined) {
84
+ return { key: inlineKey }
85
+ }
86
+ if (value.startsWith('key::')) {
87
+ return {
88
+ reason: 'configured-key-invalid',
89
+ detail: 'configured inline value is not a valid SSH public key'
90
+ }
91
+ }
92
+
93
+ let keyPath: string
94
+ if (value.startsWith('~/')) {
95
+ if (options.homeDir === undefined) {
96
+ return {
97
+ reason: 'configured-key-unreadable',
98
+ detail: 'configured public-key file could not be read'
99
+ }
100
+ }
101
+ keyPath = join(options.homeDir, value.slice(2))
102
+ } else {
103
+ keyPath = isAbsolute(value) ? value : resolve(options.workspaceFolder, value)
104
+ }
105
+
106
+ let publicKeyText: string
107
+ try {
108
+ publicKeyText = readFileSync(keyPath, 'utf8')
109
+ } catch {
110
+ return {
111
+ reason: 'configured-key-unreadable',
112
+ detail: 'configured public-key file could not be read'
113
+ }
114
+ }
115
+
116
+ const publicKey = parseSshPublicKey(publicKeyText.split(/\r?\n/u).find((line) => line.trim().length > 0) ?? '')
117
+ return publicKey === undefined
118
+ ? {
119
+ reason: 'configured-key-invalid',
120
+ detail: 'configured public-key file does not contain a valid SSH public key'
121
+ }
122
+ : { key: publicKey }
123
+ }
124
+
125
+ export function selectGitSigningKey (identities: string[], configuredKey?: string, githubKeys?: string[]): { key?: string, reason?: GitSigningReason } {
126
+ const keys = identities.map(parseSshPublicKey).filter((key): key is string => key !== undefined)
127
+ if (keys.length === 0) return { reason: 'no-identities' }
128
+
129
+ const configured = configuredKey === undefined ? undefined : parseSshPublicKey(configuredKey)
130
+ if (configuredKey !== undefined && configured === undefined) {
131
+ return { reason: 'configured-key-invalid' }
132
+ }
133
+ if (configured !== undefined) {
134
+ return keys.includes(configured) ? { key: configured } : { reason: 'configured-key-not-loaded' }
135
+ }
136
+
137
+ const github = new Set((githubKeys ?? []).map(parseSshPublicKey).filter((key): key is string => key !== undefined))
138
+ const matches = keys.filter((key) => github.has(key))
139
+ if (matches.length === 1) return { key: matches[0] }
140
+ if (keys.length === 1) return { key: keys[0] }
141
+ return { reason: 'ambiguous-identities' }
142
+ }
143
+
144
+ export function writeGitSigningPublicKey (context: WorkspaceContext, key: string): void {
145
+ mkdirSync(context.gitSigningStateDir, { recursive: true, mode: 0o700 })
146
+ writeFileSync(join(context.gitSigningStateDir, 'signing-key.pub'), `${parseSshPublicKey(key) ?? key}\n`, { mode: 0o644 })
147
+ }
148
+
149
+ type GitSigningCommandRunner = (command: string, args: string[]) => Promise<CommandResult>
150
+
151
+ interface ResolveGitSigningPlanOptions {
152
+ env?: NodeJS.ProcessEnv
153
+ platform?: NodeJS.Platform
154
+ runCommand?: GitSigningCommandRunner
155
+ }
156
+
157
+ type AgentMountProbeResult =
158
+ | { ok: true }
159
+ | { ok: false, reason: Extract<GitSigningReason, 'docker-probe-image-unavailable' | 'agent-mount-unavailable'>, detail: string }
160
+
161
+ async function runGitSigningCommand (command: string, args: string[]): Promise<CommandResult> {
162
+ return runBuffered(command, args, { mirrorStdout: false, mirrorStderr: false })
163
+ }
164
+
165
+ function failedCommandDetail (label: string, result: CommandResult): string {
166
+ const stderr = compactDiagnosticDetail(result.stderr)
167
+ return stderr.length === 0
168
+ ? `${label} failed with exit code ${result.code}`
169
+ : `${label} failed with exit code ${result.code}: ${stderr}`
170
+ }
171
+
172
+ async function probeDockerAgentMount (source: string, runCommand: GitSigningCommandRunner): Promise<AgentMountProbeResult> {
173
+ const images = await runCommand('docker', ['image', 'ls', '--format', '{{.Repository}}:{{.Tag}}'])
174
+ const image = images.stdout.split(/\r?\n/).map((value) => value.trim()).find((value) => value.length > 0 && value !== '<none>:<none>')
175
+ if (images.code !== 0) {
176
+ return {
177
+ ok: false,
178
+ reason: 'agent-mount-unavailable',
179
+ detail: failedCommandDetail('Docker image listing', images)
180
+ }
181
+ }
182
+ if (image === undefined) {
183
+ return {
184
+ ok: false,
185
+ reason: 'docker-probe-image-unavailable',
186
+ detail: 'no tagged local Docker image was found'
187
+ }
188
+ }
189
+
190
+ const created = await runCommand('docker', ['create', '--pull=never', '--entrypoint', '/bin/true', '--mount', `type=bind,source=${source},target=/run/boxdown/ssh-agent.sock`, image])
191
+ const containerId = created.stdout.trim().split(/\r?\n/)[0]
192
+ if (created.code !== 0 || containerId === undefined || containerId.length === 0) {
193
+ return {
194
+ ok: false,
195
+ reason: 'agent-mount-unavailable',
196
+ detail: failedCommandDetail('Docker SSH-agent mount probe', created)
197
+ }
198
+ }
199
+ await runCommand('docker', ['rm', '-f', containerId])
200
+ return { ok: true }
201
+ }
202
+
203
+ export async function resolveGitSigningPlan (context: WorkspaceContext, options: ResolveGitSigningPlanOptions = {}): Promise<GitSigningPlan> {
204
+ const runCommand = options.runCommand ?? runGitSigningCommand
205
+ const result = await runCommand('ssh-add', ['-L'])
206
+ if (result.code !== 0) {
207
+ return {
208
+ enabled: false,
209
+ reason: 'agent-unavailable',
210
+ detail: failedCommandDetail('ssh-add -L', result)
211
+ }
212
+ }
213
+
214
+ const identities = result.stdout.split(/\r?\n/)
215
+ const format = await runCommand('git', ['config', '--global', '--get', 'gpg.format'])
216
+ let configuredKey: string | undefined
217
+ if (format.code === 0 && format.stdout.trim() === 'ssh') {
218
+ const signingKey = await runCommand('git', ['config', '--global', '--get', 'user.signingkey'])
219
+ if (signingKey.code === 0 && signingKey.stdout.trim().length > 0) {
220
+ const resolvedKey = resolveConfiguredSshSigningKey(signingKey.stdout.trim(), {
221
+ homeDir: options.env?.HOME ?? process.env.HOME,
222
+ workspaceFolder: context.workspaceFolder
223
+ })
224
+ if (resolvedKey.key === undefined) {
225
+ return {
226
+ enabled: false,
227
+ reason: resolvedKey.reason ?? 'configured-key-invalid',
228
+ detail: resolvedKey.detail
229
+ }
230
+ }
231
+ configuredKey = resolvedKey.key
232
+ }
233
+ }
234
+ let githubKeys: string[] | undefined
235
+ if (configuredKey === undefined && identities.filter((key) => parseSshPublicKey(key) !== undefined).length > 1) {
236
+ const user = await runCommand('gh', ['api', 'user', '--jq', '.login'])
237
+ const login = user.stdout.trim()
238
+ if (user.code === 0 && login.length > 0) {
239
+ const github = await runCommand('gh', ['api', `users/${login}/keys`, '--paginate', '--jq', '.[].key'])
240
+ if (github.code === 0) githubKeys = github.stdout.split(/\r?\n/)
241
+ }
242
+ }
243
+ const selected = selectGitSigningKey(identities, configuredKey, githubKeys)
244
+ if (selected.key === undefined) return { enabled: false, reason: selected.reason }
245
+
246
+ const agentSocketSource = (options.platform ?? process.platform) === 'darwin'
247
+ ? '/run/host-services/ssh-auth.sock'
248
+ : options.env?.SSH_AUTH_SOCK ?? process.env.SSH_AUTH_SOCK
249
+ if (agentSocketSource === undefined || agentSocketSource.length === 0) {
250
+ return {
251
+ enabled: false,
252
+ reason: 'agent-socket-unavailable',
253
+ detail: 'SSH_AUTH_SOCK is not set'
254
+ }
255
+ }
256
+ const mountProbe = await probeDockerAgentMount(agentSocketSource, runCommand)
257
+ if (!mountProbe.ok) {
258
+ return {
259
+ enabled: false,
260
+ reason: mountProbe.reason,
261
+ detail: mountProbe.detail
262
+ }
263
+ }
264
+
265
+ writeGitSigningPublicKey(context, selected.key)
266
+ return { enabled: true, publicKey: selected.key, agentSocketSource }
267
+ }