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.
- package/assets/devcontainer/README.md +17 -5
- package/assets/devcontainer/devcontainer.json +3 -8
- package/assets/devcontainer/hooks/initialize.sh +49 -27
- package/assets/devcontainer/hooks/post-create.sh +11 -0
- 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-BDgyf2t5.cjs} +459 -199
- package/dist/{main-XMBsKjIK.mjs → main-J4_2Up3o.mjs} +461 -201
- package/dist/main-J4_2Up3o.mjs.map +1 -0
- package/dist/main.cjs +1 -1
- package/dist/main.d.cts +6 -0
- package/dist/main.d.cts.map +1 -1
- package/dist/main.d.mts +6 -0
- package/dist/main.d.mts.map +1 -1
- package/dist/main.mjs +1 -1
- 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 +6 -0
- package/docs/features/start-and-shell.md +5 -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/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/package.json +1 -1
- package/src/config.ts +14 -2
- package/src/constants.ts +7 -0
- package/src/devcontainer.ts +37 -26
- package/src/doctor.ts +120 -23
- package/src/git-signing.ts +205 -25
- package/src/logging.ts +11 -1
- package/src/main.ts +2 -1
- package/src/paths.ts +21 -1
- 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,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
|
|
74
|
-
? sshAgent.stdout.split(/\r?\n/).filter((line) => line.trim().startsWith('ssh-'))
|
|
75
|
-
:
|
|
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 &&
|
|
167
|
+
level: sshAgent.code === 0 && selected.key !== undefined ? 'ok' : 'warn',
|
|
79
168
|
message: sshAgent.code !== 0
|
|
80
|
-
? '
|
|
81
|
-
:
|
|
82
|
-
?
|
|
83
|
-
:
|
|
84
|
-
? 'SSH
|
|
85
|
-
:
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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 &&
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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 &&
|
|
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(
|
|
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 {
|
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 {
|
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
|
}
|