boxdown 1.2.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 (42) hide show
  1. package/assets/devcontainer/README.md +17 -5
  2. package/assets/devcontainer/devcontainer.json +3 -8
  3. package/assets/devcontainer/hooks/initialize.sh +49 -27
  4. package/assets/devcontainer/hooks/post-create.sh +11 -0
  5. package/assets/devcontainer/hooks/post-start.sh +0 -10
  6. package/assets/devcontainer/utils/git-signing-bootstrap.sh +58 -5
  7. package/assets/devcontainer/utils/secret-env-bootstrap.sh +22 -0
  8. package/assets/devcontainer/utils/ssh-agent-proxy-bootstrap.sh +27 -0
  9. package/assets/devcontainer/utils/ssh-agent-proxy.mjs +39 -0
  10. package/dist/bin/cli.cjs +1 -1
  11. package/dist/bin/cli.mjs +1 -1
  12. package/dist/{main-Df4E8ARj.cjs → main-BDgyf2t5.cjs} +459 -199
  13. package/dist/{main-XMBsKjIK.mjs → main-J4_2Up3o.mjs} +461 -201
  14. package/dist/main-J4_2Up3o.mjs.map +1 -0
  15. package/dist/main.cjs +1 -1
  16. package/dist/main.d.cts +6 -0
  17. package/dist/main.d.cts.map +1 -1
  18. package/dist/main.d.mts +6 -0
  19. package/dist/main.d.mts.map +1 -1
  20. package/dist/main.mjs +1 -1
  21. package/docs/features/commit-signing.md +71 -0
  22. package/docs/features/generated-config-and-state.md +19 -4
  23. package/docs/features/github-auth-refresh.md +3 -2
  24. package/docs/features/lifecycle.md +6 -0
  25. package/docs/features/start-and-shell.md +5 -0
  26. package/docs/superpowers/plans/2026-07-17-node-ssh-agent-proxy-signing.md +132 -0
  27. package/docs/superpowers/plans/2026-07-17-runtime-secret-environment.md +174 -0
  28. package/docs/superpowers/specs/2026-07-11-default-commit-signing-design.md +20 -0
  29. package/docs/superpowers/specs/2026-07-17-node-ssh-agent-proxy-design.md +63 -0
  30. package/docs/superpowers/specs/2026-07-17-runtime-secret-environment-design.md +194 -0
  31. package/package.json +1 -1
  32. package/src/config.ts +14 -2
  33. package/src/constants.ts +7 -0
  34. package/src/devcontainer.ts +37 -26
  35. package/src/doctor.ts +120 -23
  36. package/src/git-signing.ts +205 -25
  37. package/src/logging.ts +11 -1
  38. package/src/main.ts +2 -1
  39. package/src/paths.ts +21 -1
  40. package/src/purge.ts +8 -0
  41. package/src/shell.ts +6 -0
  42. package/dist/main-XMBsKjIK.mjs.map +0 -1
package/src/doctor.ts CHANGED
@@ -1,7 +1,10 @@
1
- import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
2
- import { join } from 'node:path'
1
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
3
 
4
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'
5
8
  import type { WorkspaceContext } from './paths.ts'
6
9
  import { runBuffered } from './process.ts'
7
10
 
@@ -40,6 +43,36 @@ function check (name: string, pass: boolean, okMessage: string, failMessage: str
40
43
  }
41
44
  }
42
45
 
46
+ function secretEnvironmentConfigCheck (context: WorkspaceContext): DoctorCheck {
47
+ let config: DevcontainerConfig
48
+
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
+ }
74
+ }
75
+
43
76
  async function runDoctorCommand (command: string, args: string[]): Promise<DoctorCommandResult> {
44
77
  return runBuffered(command, args, {
45
78
  mirrorStdout: false,
@@ -70,19 +103,77 @@ export async function runDoctorChecks (context: WorkspaceContext, options: RunDo
70
103
  ))
71
104
 
72
105
  const sshAgent = await runCommand('ssh-add', ['-L'])
73
- const identities = sshAgent.code === 0
74
- ? sshAgent.stdout.split(/\r?\n/).filter((line) => line.trim().startsWith('ssh-')).length
75
- : 0
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
+ }
76
165
  checks.push({
77
166
  name: 'git-signing-agent',
78
- level: sshAgent.code === 0 && identities === 1 ? 'ok' : 'warn',
167
+ level: sshAgent.code === 0 && selected.key !== undefined ? 'ok' : 'warn',
79
168
  message: sshAgent.code !== 0
80
- ? 'SSH agent is unavailable; Boxdown commits will remain unsigned'
81
- : identities === 0
82
- ? 'SSH agent has no identities; Boxdown commits will remain unsigned'
83
- : identities > 1
84
- ? 'SSH agent has multiple identities; Boxdown will not guess a signing key and commits will remain unsigned'
85
- : 'One SSH agent identity is available for Boxdown commit signing'
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'
86
177
  })
87
178
 
88
179
  checks.push(check(
@@ -133,26 +224,29 @@ export async function runDoctorChecks (context: WorkspaceContext, options: RunDo
133
224
  `Missing Boxdown devcontainer assets: ${context.assetsDevcontainerDir}`
134
225
  ))
135
226
 
136
- if (options.includeOptional ?? true) {
137
- if (await commandWorks(runCommand, 'gh', ['--version'])) {
138
- const ghAuth = await commandWorks(runCommand, 'gh', ['auth', 'status', '--hostname', 'github.com'])
227
+ checks.push(secretEnvironmentConfigCheck(context))
228
+
229
+ if (includeOptional) {
230
+ if (ghAvailable) {
139
231
  checks.push({
140
232
  name: 'gh-auth',
141
233
  level: ghAuth ? 'ok' : 'warn',
142
234
  message: ghAuth ? 'GitHub CLI auth is available' : 'GitHub CLI is available but not authenticated'
143
235
  })
144
- if (ghAuth && identities === 1) {
145
- const user = await runCommand('gh', ['api', 'user', '--jq', '.login'])
146
- const signing = user.code === 0 && user.stdout.trim().length > 0
147
- ? await runCommand('gh', ['api', `users/${user.stdout.trim()}/ssh_signing_keys`, '--paginate', '--jq', '.[].key'])
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'])
148
243
  : { code: 1, stdout: '', stderr: '' }
149
- const identity = sshAgent.stdout.split(/\r?\n/).find((line) => line.trim().startsWith('ssh-'))?.trim()
150
244
  checks.push({
151
245
  name: 'git-signing-github',
152
- level: signing.code === 0 && identity !== undefined && signing.stdout.includes(identity.split(/\s+/, 3).slice(0, 2).join(' ')) ? 'ok' : 'warn',
246
+ level: signing.code === 0 && signing.stdout.includes(selected.key) ? 'ok' : 'warn',
153
247
  message: signing.code !== 0
154
248
  ? 'GitHub SSH signing-key registration could not be checked'
155
- : signing.stdout.includes(identity?.split(/\s+/, 3).slice(0, 2).join(' ') ?? '')
249
+ : signing.stdout.includes(selected.key)
156
250
  ? 'Selected SSH key is registered with GitHub for commit signing'
157
251
  : 'Register the selected public key with GitHub as a signing key to receive Verified badges'
158
252
  })
@@ -215,10 +309,13 @@ async function checkDockerBindMounts (
215
309
 
216
310
  mkdirSync(context.workspaceDataDir, { recursive: true })
217
311
  const runtimeProbeDir = mkdtempSync(join(context.workspaceDataDir, 'doctor-mount-probe-'))
312
+ mkdirSync(context.workspaceSecretEnvDir, { recursive: true, mode: 0o700 })
313
+ chmodSync(context.workspaceSecretEnvDir, 0o700)
218
314
  const sources: DockerMountSource[] = [
219
315
  { label: 'workspace', path: context.workspaceFolder },
220
316
  { label: 'Boxdown devcontainer assets', path: context.assetsDevcontainerDir },
221
- { label: 'Boxdown runtime state', path: runtimeProbeDir }
317
+ { label: 'Boxdown runtime state', path: runtimeProbeDir },
318
+ { label: 'Boxdown runtime secret state', path: context.workspaceSecretEnvDir }
222
319
  ]
223
320
 
224
321
  try {
@@ -1,18 +1,64 @@
1
- import { mkdirSync, writeFileSync } from 'node:fs'
2
- import { join } from 'node:path'
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { isAbsolute, join, resolve } from 'node:path'
3
3
 
4
+ import type { WorkspaceCommandLogger } from './logging.ts'
4
5
  import type { WorkspaceContext } from './paths.ts'
5
- import { runBuffered } from './process.ts'
6
+ import { runBuffered, type CommandResult } from './process.ts'
6
7
 
7
- export type GitSigningReason = 'agent-unavailable' | 'no-identities' | 'ambiguous-identities' | 'configured-key-not-loaded' | 'agent-mount-unavailable'
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'
8
9
 
9
10
  export interface GitSigningPlan {
10
11
  enabled: boolean
11
12
  reason?: GitSigningReason
13
+ detail?: string
12
14
  publicKey?: string
13
15
  agentSocketSource?: string
14
16
  }
15
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
+
16
62
  export function parseSshPublicKey (value: string): string | undefined {
17
63
  const [algorithm, key] = value.trim().split(/\s+/, 3)
18
64
  if (algorithm === undefined || key === undefined || !/^ssh-[A-Za-z0-9-]+$/.test(algorithm) || key.length === 0) {
@@ -22,11 +68,68 @@ export function parseSshPublicKey (value: string): string | undefined {
22
68
  return `${algorithm} ${key}`
23
69
  }
24
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
+
25
125
  export function selectGitSigningKey (identities: string[], configuredKey?: string, githubKeys?: string[]): { key?: string, reason?: GitSigningReason } {
26
126
  const keys = identities.map(parseSshPublicKey).filter((key): key is string => key !== undefined)
27
127
  if (keys.length === 0) return { reason: 'no-identities' }
28
128
 
29
129
  const configured = configuredKey === undefined ? undefined : parseSshPublicKey(configuredKey)
130
+ if (configuredKey !== undefined && configured === undefined) {
131
+ return { reason: 'configured-key-invalid' }
132
+ }
30
133
  if (configured !== undefined) {
31
134
  return keys.includes(configured) ? { key: configured } : { reason: 'configured-key-not-loaded' }
32
135
  }
@@ -43,44 +146,121 @@ export function writeGitSigningPublicKey (context: WorkspaceContext, key: string
43
146
  writeFileSync(join(context.gitSigningStateDir, 'signing-key.pub'), `${parseSshPublicKey(key) ?? key}\n`, { mode: 0o644 })
44
147
  }
45
148
 
46
- async function dockerCanMountAgent (source: string): Promise<boolean> {
47
- const images = await runBuffered('docker', ['image', 'ls', '--format', '{{.Repository}}:{{.Tag}}'], { mirrorStdout: false, mirrorStderr: false })
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}}'])
48
174
  const image = images.stdout.split(/\r?\n/).map((value) => value.trim()).find((value) => value.length > 0 && value !== '<none>:<none>')
49
- if (images.code !== 0 || image === undefined) return false
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
+ }
50
189
 
51
- const created = await runBuffered('docker', ['create', '--pull=never', '--entrypoint', '/bin/true', '--mount', `type=bind,source=${source},target=/run/boxdown/ssh-agent.sock`, image], { mirrorStdout: false, mirrorStderr: false })
190
+ const created = await runCommand('docker', ['create', '--pull=never', '--entrypoint', '/bin/true', '--mount', `type=bind,source=${source},target=/run/boxdown/ssh-agent.sock`, image])
52
191
  const containerId = created.stdout.trim().split(/\r?\n/)[0]
53
- if (created.code !== 0 || containerId === undefined || containerId.length === 0) return false
54
- await runBuffered('docker', ['rm', '-f', containerId], { mirrorStdout: false, mirrorStderr: false })
55
- return true
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 }
56
201
  }
57
202
 
58
- export async function resolveGitSigningPlan (context: WorkspaceContext): Promise<GitSigningPlan> {
59
- const result = await runBuffered('ssh-add', ['-L'], { mirrorStdout: false, mirrorStderr: false })
60
- if (result.code !== 0) return { enabled: false, reason: 'agent-unavailable' }
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
+ }
61
213
 
62
214
  const identities = result.stdout.split(/\r?\n/)
63
- const format = await runBuffered('git', ['config', '--global', '--get', 'gpg.format'], { mirrorStdout: false, mirrorStderr: false })
64
- const configuredKey = format.code === 0 && format.stdout.trim() === 'ssh'
65
- ? (await runBuffered('git', ['config', '--global', '--get', 'user.signingkey'], { mirrorStdout: false, mirrorStderr: false })).stdout.trim()
66
- : undefined
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
+ }
67
234
  let githubKeys: string[] | undefined
68
- if (identities.filter((key) => parseSshPublicKey(key) !== undefined).length > 1) {
69
- const user = await runBuffered('gh', ['api', 'user', '--jq', '.login'], { mirrorStdout: false, mirrorStderr: false })
235
+ if (configuredKey === undefined && identities.filter((key) => parseSshPublicKey(key) !== undefined).length > 1) {
236
+ const user = await runCommand('gh', ['api', 'user', '--jq', '.login'])
70
237
  const login = user.stdout.trim()
71
238
  if (user.code === 0 && login.length > 0) {
72
- const github = await runBuffered('gh', ['api', `users/${login}/keys`, '--paginate', '--jq', '.[].key'], { mirrorStdout: false, mirrorStderr: false })
239
+ const github = await runCommand('gh', ['api', `users/${login}/keys`, '--paginate', '--jq', '.[].key'])
73
240
  if (github.code === 0) githubKeys = github.stdout.split(/\r?\n/)
74
241
  }
75
242
  }
76
243
  const selected = selectGitSigningKey(identities, configuredKey, githubKeys)
77
244
  if (selected.key === undefined) return { enabled: false, reason: selected.reason }
78
245
 
79
- const agentSocketSource = process.platform === 'darwin'
246
+ const agentSocketSource = (options.platform ?? process.platform) === 'darwin'
80
247
  ? '/run/host-services/ssh-auth.sock'
81
- : process.env.SSH_AUTH_SOCK
82
- if (agentSocketSource === undefined || agentSocketSource.length === 0) return { enabled: false, reason: 'agent-mount-unavailable' }
83
- if (!await dockerCanMountAgent(agentSocketSource)) return { enabled: false, reason: 'agent-mount-unavailable' }
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
+ }
84
264
 
85
265
  writeGitSigningPublicKey(context, selected.key)
86
266
  return { enabled: true, publicKey: selected.key, agentSocketSource }
package/src/logging.ts CHANGED
@@ -24,6 +24,13 @@ function argvText (command: string, args: string[]): string {
24
24
  return JSON.stringify([command, ...args])
25
25
  }
26
26
 
27
+ export function redactKnownSecretEnvironmentAssignments (value: string): string {
28
+ return value
29
+ .replace(/ANTHROPIC_API_KEY=[^\s,"\]}]+/gu, 'ANTHROPIC_API_KEY=[redacted]')
30
+ .replace(/SNYK_TOKEN=[^\s,"\]}]+/gu, 'SNYK_TOKEN=[redacted]')
31
+ .replace(/OP_SERVICE_ACCOUNT_TOKEN=[^\s,"\]}]+/gu, 'OP_SERVICE_ACCOUNT_TOKEN=[redacted]')
32
+ }
33
+
27
34
  export class WorkspaceCommandLogger {
28
35
  readonly logPath: string
29
36
  readonly workspaceFolder: string
@@ -89,7 +96,10 @@ export class WorkspaceCommandLogger {
89
96
  }
90
97
 
91
98
  #redact (value: string): string {
92
- return this.#redactions.reduce((current, redaction) => current.replaceAll(redaction, '[redacted]'), value)
99
+ return this.#redactions.reduce(
100
+ (current, redaction) => current.replaceAll(redaction, '[redacted]'),
101
+ redactKnownSecretEnvironmentAssignments(value)
102
+ )
93
103
  }
94
104
 
95
105
  #stream (stream: LogStreamName, chunk: Buffer | string): void {
package/src/main.ts CHANGED
@@ -13,7 +13,7 @@ import { listWorkspaceMetadata, readWorkspaceMetadata, writeWorkspaceMetadata, t
13
13
  import { readPackageVersion } from './package-info.ts'
14
14
  import { createWorkspaceContext, createWorkspaceContextFromIdentity, defaultDataRoot, type WorkspaceContext } from './paths.ts'
15
15
  import { createProgress, resolveProgressMode, type ProgressReporter, type ProgressOutputTarget, type ProgressStepDefinition } from './progress.ts'
16
- import { purgeWorkspace } from './purge.ts'
16
+ import { purgeWorkspace, removeWorkspaceRuntimeState } from './purge.ts'
17
17
  import { defaultSshAlias, installSshConfig, uninstallSshConfig } from './ssh-config.ts'
18
18
  import { dedupeSshInstallTargets, installSshInstallTarget, isSshConfigInstallTarget, SSH_INSTALL_TARGETS, sshInstallTargetFlagHintsText, supportedSshInstallTargetsText, type SshConfigInstallTarget } from './ssh-install-targets.ts'
19
19
  import { createStatusInfo, formatStatusText, statusIsHealthy } from './status.ts'
@@ -831,6 +831,7 @@ async function runDownCommand (workspaces: string[] | undefined, options: RunCli
831
831
  const context = createWorkspaceContext({ workspace })
832
832
  await runLoggedLifecycle(context, 'down', ['down', ...(workspace === undefined ? [] : ['--workspace', workspace])], async (logger) => {
833
833
  await removeWorkspaceContainer(context, { logger })
834
+ removeWorkspaceRuntimeState(context)
834
835
  })
835
836
  } catch (error) {
836
837
  failed = true
package/src/paths.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto'
2
2
  import { existsSync, realpathSync, statSync } from 'node:fs'
3
- import { homedir } from 'node:os'
3
+ import { homedir, tmpdir } from 'node:os'
4
4
  import { basename, dirname, join, resolve } from 'node:path'
5
5
  import { fileURLToPath } from 'node:url'
6
6
 
@@ -22,8 +22,11 @@ export interface WorkspaceContext {
22
22
  assetsDevcontainerDir: string
23
23
  cacheRoot: string
24
24
  dataRoot: string
25
+ runtimeRoot: string
25
26
  workspaceCacheDir: string
26
27
  workspaceDataDir: string
28
+ workspaceRuntimeDir: string
29
+ workspaceSecretEnvDir: string
27
30
  generatedConfigPath: string
28
31
  hostAgentsDir: string
29
32
  hostCodexAuthPath: string
@@ -92,6 +95,18 @@ export function defaultDataRoot (env: NodeJS.ProcessEnv = process.env): string {
92
95
  return join(homedir(), '.local', 'share', PACKAGE_NAME)
93
96
  }
94
97
 
98
+ export function defaultRuntimeRoot (env: NodeJS.ProcessEnv = process.env): string {
99
+ if (env.BOXDOWN_RUNTIME_HOME) {
100
+ return env.BOXDOWN_RUNTIME_HOME
101
+ }
102
+
103
+ if (env.XDG_RUNTIME_DIR) {
104
+ return join(env.XDG_RUNTIME_DIR, PACKAGE_NAME)
105
+ }
106
+
107
+ return join(tmpdir(), `${PACKAGE_NAME}-${process.getuid?.() ?? 'user'}`)
108
+ }
109
+
95
110
  export function defaultHostAgentsDir (env: NodeJS.ProcessEnv = process.env): string {
96
111
  return join(env.HOME ?? homedir(), '.agents')
97
112
  }
@@ -113,10 +128,12 @@ export function createWorkspaceContextFromIdentity (
113
128
  const assetsDevcontainerDir = options.assetsDevcontainerDir ?? env.BOXDOWN_DEVCONTAINER_ASSETS_DIR ?? join(packageRoot, 'assets', 'devcontainer')
114
129
  const cacheRoot = defaultCacheRoot(env)
115
130
  const dataRoot = defaultDataRoot(env)
131
+ const runtimeRoot = defaultRuntimeRoot(env)
116
132
  const hostAgentsDir = defaultHostAgentsDir(env)
117
133
  const hostCodexAuthPath = defaultHostCodexAuthPath(env)
118
134
  const workspaceCacheDir = join(cacheRoot, 'workspaces', identity.workspaceId)
119
135
  const workspaceDataDir = join(dataRoot, 'workspaces', identity.workspaceId)
136
+ const workspaceRuntimeDir = join(runtimeRoot, 'workspaces', identity.workspaceId)
120
137
  const hostGitconfigSnapshotDir = join(workspaceDataDir, 'gitconfig')
121
138
 
122
139
  return {
@@ -127,8 +144,11 @@ export function createWorkspaceContextFromIdentity (
127
144
  assetsDevcontainerDir,
128
145
  cacheRoot,
129
146
  dataRoot,
147
+ runtimeRoot,
130
148
  workspaceCacheDir,
131
149
  workspaceDataDir,
150
+ workspaceRuntimeDir,
151
+ workspaceSecretEnvDir: join(workspaceRuntimeDir, 'secrets'),
132
152
  generatedConfigPath: join(workspaceCacheDir, 'devcontainer.json'),
133
153
  hostAgentsDir,
134
154
  hostCodexAuthPath,
package/src/purge.ts CHANGED
@@ -85,6 +85,10 @@ function removeWorkspaceStateDir (
85
85
  process.stdout.write(`Removed ${label}: ${path}\n`)
86
86
  }
87
87
 
88
+ export function removeWorkspaceRuntimeState (context: WorkspaceContext): void {
89
+ removeWorkspaceStateDir(context, 'workspace runtime directory', context.workspaceRuntimeDir, context.runtimeRoot)
90
+ }
91
+
88
92
  async function purgeAliasIntegrations (context: WorkspaceContext, alias: string): Promise<boolean> {
89
93
  let failed = false
90
94
 
@@ -194,6 +198,10 @@ export async function purgeWorkspace (context: WorkspaceContext, options: PurgeO
194
198
  }) || failed
195
199
  }
196
200
 
201
+ failed = await runPurgeStep('workspace runtime directory', () => {
202
+ removeWorkspaceRuntimeState(context)
203
+ }) || failed
204
+
197
205
  failed = await runPurgeStep('workspace cache directory', () => {
198
206
  removeWorkspaceStateDir(context, 'workspace cache', context.workspaceCacheDir, context.cacheRoot)
199
207
  }) || failed
package/src/shell.ts CHANGED
@@ -61,10 +61,15 @@ function interactiveAgentPathScript (): string {
61
61
  ].join('\n')
62
62
  }
63
63
 
64
+ function runtimeSecretEnvironmentScript (): string {
65
+ return 'source "${BASH_ENV:-/opt/boxdown/devcontainer/utils/secret-env-bootstrap.sh}"'
66
+ }
67
+
64
68
  export function interactiveShellScript (): string {
65
69
  return [
66
70
  interactiveTermSetupScript(),
67
71
  interactiveTtySetupScript(),
72
+ runtimeSecretEnvironmentScript(),
68
73
  'exec bash -i'
69
74
  ].join('\n')
70
75
  }
@@ -74,6 +79,7 @@ export function interactiveCommandScript (): string {
74
79
  interactiveTermSetupScript(),
75
80
  interactiveTtySetupScript(),
76
81
  interactiveAgentPathScript(),
82
+ runtimeSecretEnvironmentScript(),
77
83
  'exec "$@"'
78
84
  ].join('\n')
79
85
  }