boxdown 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/assets/devcontainer/README.md +29 -6
- package/assets/devcontainer/devcontainer.json +4 -9
- package/assets/devcontainer/hooks/initialize.sh +49 -27
- package/assets/devcontainer/hooks/post-create.sh +23 -1
- package/assets/devcontainer/hooks/post-start.sh +0 -10
- package/assets/devcontainer/utils/git-signing-bootstrap.sh +58 -5
- package/assets/devcontainer/utils/secret-env-bootstrap.sh +22 -0
- package/assets/devcontainer/utils/ssh-agent-proxy-bootstrap.sh +27 -0
- package/assets/devcontainer/utils/ssh-agent-proxy.mjs +39 -0
- package/dist/bin/cli.cjs +1 -1
- package/dist/bin/cli.mjs +1 -1
- package/dist/{main-Df4E8ARj.cjs → main-CT2n9qcb.cjs} +907 -266
- package/dist/{main-XMBsKjIK.mjs → main-O__JaiXe.mjs} +891 -268
- package/dist/main-O__JaiXe.mjs.map +1 -0
- package/dist/main.cjs +4 -1
- package/dist/main.d.cts +98 -26
- package/dist/main.d.cts.map +1 -1
- package/dist/main.d.mts +98 -26
- package/dist/main.d.mts.map +1 -1
- package/dist/main.mjs +2 -2
- package/docs/features/commit-signing.md +71 -0
- package/docs/features/generated-config-and-state.md +19 -4
- package/docs/features/github-auth-refresh.md +3 -2
- package/docs/features/lifecycle.md +19 -0
- package/docs/features/setup.md +5 -0
- package/docs/features/start-and-shell.md +10 -0
- package/docs/superpowers/plans/2026-07-17-node-ssh-agent-proxy-signing.md +132 -0
- package/docs/superpowers/plans/2026-07-17-runtime-secret-environment.md +174 -0
- package/docs/superpowers/plans/2026-07-18-architecture-aware-1password-installer.md +163 -0
- package/docs/superpowers/plans/2026-07-18-devcontainer-node-image-digest.md +341 -0
- package/docs/superpowers/plans/2026-07-19-container-runtime-readiness.md +1536 -0
- package/docs/superpowers/specs/2026-07-11-default-commit-signing-design.md +20 -0
- package/docs/superpowers/specs/2026-07-17-node-ssh-agent-proxy-design.md +63 -0
- package/docs/superpowers/specs/2026-07-17-runtime-secret-environment-design.md +194 -0
- package/docs/superpowers/specs/2026-07-18-architecture-aware-1password-installer-design.md +63 -0
- package/docs/superpowers/specs/2026-07-18-devcontainer-node-image-digest-design.md +108 -0
- package/docs/superpowers/specs/2026-07-19-container-runtime-readiness-design.md +353 -0
- package/package.json +1 -1
- package/src/config.ts +14 -2
- package/src/constants.ts +7 -0
- package/src/container-runtime.ts +295 -0
- package/src/devcontainer.ts +57 -30
- package/src/doctor.ts +169 -46
- package/src/git-signing.ts +205 -25
- package/src/logging.ts +11 -1
- package/src/main.ts +97 -12
- package/src/paths.ts +21 -1
- package/src/process.ts +50 -10
- package/src/progress.ts +63 -8
- package/src/purge.ts +8 -0
- package/src/shell.ts +6 -0
- package/dist/main-XMBsKjIK.mjs.map +0 -1
package/src/doctor.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
|
2
|
-
import { join } from 'node:path'
|
|
3
|
-
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
probeContainerRuntime,
|
|
6
|
+
type ContainerRuntimeCommandResult,
|
|
7
|
+
type ContainerRuntimeCommandRunner
|
|
8
|
+
} from './container-runtime.ts'
|
|
4
9
|
import { resolveDevcontainerCli } from './devcontainer-cli.ts'
|
|
10
|
+
import { buildGeneratedDevcontainerConfig, type DevcontainerConfig } from './config.ts'
|
|
11
|
+
import { BOXDOWN_SECRET_ENV_NAMES } from './constants.ts'
|
|
12
|
+
import { resolveConfiguredSshSigningKey, selectGitSigningKey, type GitSigningReason } from './git-signing.ts'
|
|
5
13
|
import type { WorkspaceContext } from './paths.ts'
|
|
6
14
|
import { runBuffered } from './process.ts'
|
|
7
15
|
|
|
@@ -13,17 +21,13 @@ export interface DoctorCheck {
|
|
|
13
21
|
message: string
|
|
14
22
|
}
|
|
15
23
|
|
|
16
|
-
export
|
|
17
|
-
|
|
18
|
-
stdout: string
|
|
19
|
-
stderr: string
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export type DoctorCommandRunner = (command: string, args: string[]) => Promise<DoctorCommandResult>
|
|
24
|
+
export type DoctorCommandResult = ContainerRuntimeCommandResult
|
|
25
|
+
export type DoctorCommandRunner = ContainerRuntimeCommandRunner
|
|
23
26
|
|
|
24
27
|
export interface RunDoctorChecksOptions {
|
|
25
28
|
includeOptional?: boolean
|
|
26
29
|
includeDockerMountProbe?: boolean
|
|
30
|
+
containerRuntimeReady?: boolean
|
|
27
31
|
runCommand?: DoctorCommandRunner
|
|
28
32
|
}
|
|
29
33
|
|
|
@@ -40,6 +44,36 @@ function check (name: string, pass: boolean, okMessage: string, failMessage: str
|
|
|
40
44
|
}
|
|
41
45
|
}
|
|
42
46
|
|
|
47
|
+
function secretEnvironmentConfigCheck (context: WorkspaceContext): DoctorCheck {
|
|
48
|
+
let config: DevcontainerConfig
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
config = existsSync(context.generatedConfigPath)
|
|
52
|
+
? JSON.parse(readFileSync(context.generatedConfigPath, 'utf8')) as DevcontainerConfig
|
|
53
|
+
: buildGeneratedDevcontainerConfig(context)
|
|
54
|
+
} catch {
|
|
55
|
+
return {
|
|
56
|
+
name: 'secret-environment-config',
|
|
57
|
+
level: 'warn',
|
|
58
|
+
message: 'Generated config could not be checked for secret-safe environment handling'
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const runArgs = Array.isArray(config.runArgs) ? config.runArgs : []
|
|
63
|
+
const containerEnv = config.containerEnv ?? {}
|
|
64
|
+
const unsafe = runArgs.includes('--env-file') ||
|
|
65
|
+
runArgs.some((arg) => arg.includes('.env.development')) ||
|
|
66
|
+
BOXDOWN_SECRET_ENV_NAMES.some((name) => Object.hasOwn(containerEnv, name))
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
name: 'secret-environment-config',
|
|
70
|
+
level: unsafe ? 'warn' : 'ok',
|
|
71
|
+
message: unsafe
|
|
72
|
+
? 'Generated config still exposes Boxdown secrets through Docker environment settings; recreate after upgrading Boxdown'
|
|
73
|
+
: 'Generated config uses runtime-mounted secrets without Docker environment values'
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
43
77
|
async function runDoctorCommand (command: string, args: string[]): Promise<DoctorCommandResult> {
|
|
44
78
|
return runBuffered(command, args, {
|
|
45
79
|
mirrorStdout: false,
|
|
@@ -70,19 +104,77 @@ export async function runDoctorChecks (context: WorkspaceContext, options: RunDo
|
|
|
70
104
|
))
|
|
71
105
|
|
|
72
106
|
const sshAgent = await runCommand('ssh-add', ['-L'])
|
|
73
|
-
const
|
|
74
|
-
? sshAgent.stdout.split(/\r?\n/).filter((line) => line.trim().startsWith('ssh-'))
|
|
75
|
-
:
|
|
107
|
+
const identityLines = sshAgent.code === 0
|
|
108
|
+
? sshAgent.stdout.split(/\r?\n/).filter((line) => line.trim().startsWith('ssh-'))
|
|
109
|
+
: []
|
|
110
|
+
const identities = identityLines.length
|
|
111
|
+
let configuredKey: string | undefined
|
|
112
|
+
let configuredFailure: { reason: GitSigningReason, detail?: string } | undefined
|
|
113
|
+
const format = await runCommand('git', ['config', '--global', '--get', 'gpg.format'])
|
|
114
|
+
if (format.code === 0 && format.stdout.trim() === 'ssh') {
|
|
115
|
+
const signingKey = await runCommand('git', ['config', '--global', '--get', 'user.signingkey'])
|
|
116
|
+
if (signingKey.code === 0 && signingKey.stdout.trim().length > 0) {
|
|
117
|
+
const resolved = resolveConfiguredSshSigningKey(signingKey.stdout.trim(), {
|
|
118
|
+
homeDir: dirname(context.hostGitconfigPath),
|
|
119
|
+
workspaceFolder: context.workspaceFolder
|
|
120
|
+
})
|
|
121
|
+
if (resolved.key === undefined) {
|
|
122
|
+
configuredFailure = {
|
|
123
|
+
reason: resolved.reason ?? 'configured-key-invalid',
|
|
124
|
+
detail: resolved.detail
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
configuredKey = resolved.key
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const includeOptional = options.includeOptional ?? true
|
|
133
|
+
let ghAvailable = false
|
|
134
|
+
let ghAuth = false
|
|
135
|
+
let githubLogin: string | undefined
|
|
136
|
+
let githubAuthKeys: string[] | undefined
|
|
137
|
+
if (includeOptional) {
|
|
138
|
+
ghAvailable = await commandWorks(runCommand, 'gh', ['--version'])
|
|
139
|
+
if (ghAvailable) {
|
|
140
|
+
ghAuth = await commandWorks(runCommand, 'gh', ['auth', 'status', '--hostname', 'github.com'])
|
|
141
|
+
if (ghAuth && configuredKey === undefined && configuredFailure === undefined && identities > 1) {
|
|
142
|
+
const user = await runCommand('gh', ['api', 'user', '--jq', '.login'])
|
|
143
|
+
githubLogin = user.code === 0 && user.stdout.trim().length > 0 ? user.stdout.trim() : undefined
|
|
144
|
+
if (githubLogin !== undefined) {
|
|
145
|
+
const authentication = await runCommand('gh', ['api', `users/${githubLogin}/keys`, '--paginate', '--jq', '.[].key'])
|
|
146
|
+
if (authentication.code === 0) githubAuthKeys = authentication.stdout.split(/\r?\n/)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const selected: { key?: string, reason?: GitSigningReason } = configuredFailure ?? selectGitSigningKey(identityLines, configuredKey, githubAuthKeys)
|
|
153
|
+
const selectedByConfiguration = selected.key !== undefined && configuredKey !== undefined
|
|
154
|
+
const selectedByGithub = selected.key !== undefined && configuredKey === undefined && identities > 1
|
|
155
|
+
const signingMessages: Record<GitSigningReason, string> = {
|
|
156
|
+
'agent-unavailable': 'SSH agent is unavailable; Boxdown commits will remain unsigned',
|
|
157
|
+
'no-identities': 'SSH agent has no identities; Boxdown commits will remain unsigned',
|
|
158
|
+
'ambiguous-identities': 'SSH agent has multiple identities; Boxdown will not guess a signing key and commits will remain unsigned',
|
|
159
|
+
'configured-key-unreadable': 'Configured SSH signing-key file could not be read; Boxdown commits will remain unsigned',
|
|
160
|
+
'configured-key-invalid': 'Configured SSH signing key is not a valid public key; Boxdown commits will remain unsigned',
|
|
161
|
+
'configured-key-not-loaded': 'Configured SSH signing key is not loaded in the agent; Boxdown commits will remain unsigned',
|
|
162
|
+
'agent-socket-unavailable': 'Host SSH-agent socket is unavailable; Boxdown commits will remain unsigned',
|
|
163
|
+
'docker-probe-image-unavailable': 'No local Docker image is available to probe commit-signing agent forwarding',
|
|
164
|
+
'agent-mount-unavailable': 'Docker could not mount the host SSH-agent socket; Boxdown commits will remain unsigned'
|
|
165
|
+
}
|
|
76
166
|
checks.push({
|
|
77
167
|
name: 'git-signing-agent',
|
|
78
|
-
level: sshAgent.code === 0 &&
|
|
168
|
+
level: sshAgent.code === 0 && selected.key !== undefined ? 'ok' : 'warn',
|
|
79
169
|
message: sshAgent.code !== 0
|
|
80
|
-
? '
|
|
81
|
-
:
|
|
82
|
-
?
|
|
83
|
-
:
|
|
84
|
-
? 'SSH
|
|
85
|
-
:
|
|
170
|
+
? signingMessages['agent-unavailable']
|
|
171
|
+
: selected.key === undefined
|
|
172
|
+
? signingMessages[selected.reason ?? 'ambiguous-identities']
|
|
173
|
+
: selectedByConfiguration
|
|
174
|
+
? 'Configured SSH signing key is loaded in the agent'
|
|
175
|
+
: selectedByGithub
|
|
176
|
+
? 'GitHub authentication keys identify one SSH agent identity for Boxdown commit signing'
|
|
177
|
+
: 'One SSH agent identity is available for Boxdown commit signing'
|
|
86
178
|
})
|
|
87
179
|
|
|
88
180
|
checks.push(check(
|
|
@@ -92,21 +184,46 @@ export async function runDoctorChecks (context: WorkspaceContext, options: RunDo
|
|
|
92
184
|
'Packaged @devcontainers/cli is required but was not available'
|
|
93
185
|
))
|
|
94
186
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
187
|
+
let dockerCliWorks = options.containerRuntimeReady === true
|
|
188
|
+
let dockerDaemonWorks = options.containerRuntimeReady === true
|
|
189
|
+
|
|
190
|
+
if (options.containerRuntimeReady !== true) {
|
|
191
|
+
const runtime = await probeContainerRuntime(runCommand)
|
|
192
|
+
dockerCliWorks = runtime.state === 'ready' || runtime.failure.reason !== 'docker-cli-unavailable'
|
|
193
|
+
dockerDaemonWorks = runtime.state === 'ready' ||
|
|
194
|
+
(runtime.state === 'waiting' && runtime.failure.reason === 'buildx-builder-unavailable')
|
|
195
|
+
|
|
196
|
+
checks.push(check(
|
|
197
|
+
'docker-cli',
|
|
198
|
+
dockerCliWorks,
|
|
199
|
+
'Docker CLI is available',
|
|
200
|
+
'Docker CLI is required but was not available'
|
|
201
|
+
))
|
|
202
|
+
checks.push(check(
|
|
203
|
+
'docker-daemon',
|
|
204
|
+
dockerDaemonWorks,
|
|
205
|
+
'Docker daemon is reachable',
|
|
206
|
+
'Docker daemon is required but was not reachable'
|
|
207
|
+
))
|
|
208
|
+
|
|
209
|
+
if (!dockerCliWorks || !dockerDaemonWorks) {
|
|
210
|
+
checks.push({
|
|
211
|
+
name: 'docker-buildx',
|
|
212
|
+
level: 'warn',
|
|
213
|
+
message: 'Docker Buildx was not checked because the Docker runtime is unavailable'
|
|
214
|
+
})
|
|
215
|
+
} else if (runtime.state === 'ready' && runtime.mode === 'fallback') {
|
|
216
|
+
checks.push({ name: 'docker-buildx', level: 'warn', message: runtime.warnings[0] as string })
|
|
217
|
+
} else if (runtime.state === 'waiting') {
|
|
218
|
+
checks.push({
|
|
219
|
+
name: 'docker-buildx',
|
|
220
|
+
level: 'fail',
|
|
221
|
+
message: `Docker Buildx builder was not operational: ${runtime.failure.detail}`
|
|
222
|
+
})
|
|
223
|
+
} else {
|
|
224
|
+
checks.push({ name: 'docker-buildx', level: 'ok', message: 'Docker Buildx builder is operational' })
|
|
225
|
+
}
|
|
226
|
+
}
|
|
110
227
|
|
|
111
228
|
checks.push(check(
|
|
112
229
|
'ssh',
|
|
@@ -133,26 +250,29 @@ export async function runDoctorChecks (context: WorkspaceContext, options: RunDo
|
|
|
133
250
|
`Missing Boxdown devcontainer assets: ${context.assetsDevcontainerDir}`
|
|
134
251
|
))
|
|
135
252
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
253
|
+
checks.push(secretEnvironmentConfigCheck(context))
|
|
254
|
+
|
|
255
|
+
if (includeOptional) {
|
|
256
|
+
if (ghAvailable) {
|
|
139
257
|
checks.push({
|
|
140
258
|
name: 'gh-auth',
|
|
141
259
|
level: ghAuth ? 'ok' : 'warn',
|
|
142
260
|
message: ghAuth ? 'GitHub CLI auth is available' : 'GitHub CLI is available but not authenticated'
|
|
143
261
|
})
|
|
144
|
-
if (ghAuth &&
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
262
|
+
if (ghAuth && selected.key !== undefined) {
|
|
263
|
+
if (githubLogin === undefined) {
|
|
264
|
+
const user = await runCommand('gh', ['api', 'user', '--jq', '.login'])
|
|
265
|
+
githubLogin = user.code === 0 && user.stdout.trim().length > 0 ? user.stdout.trim() : undefined
|
|
266
|
+
}
|
|
267
|
+
const signing = githubLogin !== undefined
|
|
268
|
+
? await runCommand('gh', ['api', `users/${githubLogin}/ssh_signing_keys`, '--paginate', '--jq', '.[].key'])
|
|
148
269
|
: { code: 1, stdout: '', stderr: '' }
|
|
149
|
-
const identity = sshAgent.stdout.split(/\r?\n/).find((line) => line.trim().startsWith('ssh-'))?.trim()
|
|
150
270
|
checks.push({
|
|
151
271
|
name: 'git-signing-github',
|
|
152
|
-
level: signing.code === 0 &&
|
|
272
|
+
level: signing.code === 0 && signing.stdout.includes(selected.key) ? 'ok' : 'warn',
|
|
153
273
|
message: signing.code !== 0
|
|
154
274
|
? 'GitHub SSH signing-key registration could not be checked'
|
|
155
|
-
: signing.stdout.includes(
|
|
275
|
+
: signing.stdout.includes(selected.key)
|
|
156
276
|
? 'Selected SSH key is registered with GitHub for commit signing'
|
|
157
277
|
: 'Register the selected public key with GitHub as a signing key to receive Verified badges'
|
|
158
278
|
})
|
|
@@ -215,10 +335,13 @@ async function checkDockerBindMounts (
|
|
|
215
335
|
|
|
216
336
|
mkdirSync(context.workspaceDataDir, { recursive: true })
|
|
217
337
|
const runtimeProbeDir = mkdtempSync(join(context.workspaceDataDir, 'doctor-mount-probe-'))
|
|
338
|
+
mkdirSync(context.workspaceSecretEnvDir, { recursive: true, mode: 0o700 })
|
|
339
|
+
chmodSync(context.workspaceSecretEnvDir, 0o700)
|
|
218
340
|
const sources: DockerMountSource[] = [
|
|
219
341
|
{ label: 'workspace', path: context.workspaceFolder },
|
|
220
342
|
{ label: 'Boxdown devcontainer assets', path: context.assetsDevcontainerDir },
|
|
221
|
-
{ label: 'Boxdown runtime state', path: runtimeProbeDir }
|
|
343
|
+
{ label: 'Boxdown runtime state', path: runtimeProbeDir },
|
|
344
|
+
{ label: 'Boxdown runtime secret state', path: context.workspaceSecretEnvDir }
|
|
222
345
|
]
|
|
223
346
|
|
|
224
347
|
try {
|
package/src/git-signing.ts
CHANGED
|
@@ -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
|
-
|
|
47
|
-
|
|
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
|
|
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
|
|
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)
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
60
|
-
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
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
|
|
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)
|
|
83
|
-
|
|
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(
|
|
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 {
|