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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { appendFileSync, chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
-
import { homedir } from "node:os";
|
|
4
|
+
import { homedir, tmpdir } from "node:os";
|
|
5
5
|
import { basename, delimiter, dirname, isAbsolute, join, parse, relative, resolve } from "node:path";
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
7
7
|
import { createInterface } from "node:readline";
|
|
@@ -10,7 +10,7 @@ import { fileURLToPath } from "node:url";
|
|
|
10
10
|
//#region src/claude-app-config.ts
|
|
11
11
|
const CLAUDE_APP_SUPPORT_DIRNAME = "Claude";
|
|
12
12
|
const CLAUDE_SSH_CONFIGS_FILENAME = "ssh_configs.json";
|
|
13
|
-
function isRecord$
|
|
13
|
+
function isRecord$1(value) {
|
|
14
14
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
15
15
|
}
|
|
16
16
|
function parseRequiredString(value, key) {
|
|
@@ -43,7 +43,7 @@ function parseStringArray(value, key) {
|
|
|
43
43
|
});
|
|
44
44
|
}
|
|
45
45
|
function parseClaudeSshConfigEntry(value) {
|
|
46
|
-
if (!isRecord$
|
|
46
|
+
if (!isRecord$1(value)) {
|
|
47
47
|
throw new Error("Invalid Claude SSH config: configs entries must be objects");
|
|
48
48
|
}
|
|
49
49
|
const source = parseOptionalString(value.source, "source");
|
|
@@ -61,7 +61,7 @@ function parseClaudeSshConfigEntry(value) {
|
|
|
61
61
|
return parsed;
|
|
62
62
|
}
|
|
63
63
|
function parseClaudeSshConfigs(value) {
|
|
64
|
-
if (!isRecord$
|
|
64
|
+
if (!isRecord$1(value)) {
|
|
65
65
|
throw new Error("Invalid Claude SSH config: top-level value must be an object");
|
|
66
66
|
}
|
|
67
67
|
const configs = value.configs;
|
|
@@ -217,7 +217,7 @@ const CODEX_APP_CONFIG_VERSION = 1;
|
|
|
217
217
|
const CODEX_APP_CONFIG_DIRNAME = "codex-app";
|
|
218
218
|
const CODEX_APP_CONFIG_FILENAME = "config.json";
|
|
219
219
|
const CODEX_GLOBAL_STATE_FILENAME = ".codex-global-state.json";
|
|
220
|
-
function isRecord
|
|
220
|
+
function isRecord(value) {
|
|
221
221
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
222
222
|
}
|
|
223
223
|
function parseOptionalNonnegativeInteger(value, key) {
|
|
@@ -239,7 +239,7 @@ function parseOptionalLabel(value) {
|
|
|
239
239
|
return value;
|
|
240
240
|
}
|
|
241
241
|
function parseProjectConfig(value) {
|
|
242
|
-
if (!isRecord
|
|
242
|
+
if (!isRecord(value)) {
|
|
243
243
|
throw new Error("Invalid Codex app config: project entries must be objects");
|
|
244
244
|
}
|
|
245
245
|
const remotePath = value.remotePath;
|
|
@@ -253,7 +253,7 @@ function parseProjectConfig(value) {
|
|
|
253
253
|
};
|
|
254
254
|
}
|
|
255
255
|
function parseRemoteConnectionConfig(value) {
|
|
256
|
-
if (!isRecord
|
|
256
|
+
if (!isRecord(value)) {
|
|
257
257
|
throw new Error("Invalid Codex app config: remoteConnections entries must be objects");
|
|
258
258
|
}
|
|
259
259
|
const sshAlias = value.sshAlias;
|
|
@@ -270,7 +270,7 @@ function parseRemoteConnectionConfig(value) {
|
|
|
270
270
|
};
|
|
271
271
|
}
|
|
272
272
|
function parseCodexAppConfig(value) {
|
|
273
|
-
if (!isRecord
|
|
273
|
+
if (!isRecord(value)) {
|
|
274
274
|
throw new Error("Invalid Codex app config: top-level value must be an object");
|
|
275
275
|
}
|
|
276
276
|
const version = value.version;
|
|
@@ -397,7 +397,7 @@ function cloneJsonObject(value) {
|
|
|
397
397
|
return JSON.parse(JSON.stringify(value));
|
|
398
398
|
}
|
|
399
399
|
function removeObjectKey(value, key) {
|
|
400
|
-
if (isRecord
|
|
400
|
+
if (isRecord(value)) {
|
|
401
401
|
delete value[key];
|
|
402
402
|
}
|
|
403
403
|
}
|
|
@@ -417,7 +417,7 @@ function cleanupCodexStateContainer(container, hostId, remotePaths) {
|
|
|
417
417
|
const remoteProjects = container["remote-projects"];
|
|
418
418
|
if (Array.isArray(remoteProjects)) {
|
|
419
419
|
container["remote-projects"] = remoteProjects.filter((project) => {
|
|
420
|
-
if (!isRecord
|
|
420
|
+
if (!isRecord(project)) {
|
|
421
421
|
return true;
|
|
422
422
|
}
|
|
423
423
|
const matches = project.hostId === hostId && typeof project.remotePath === "string" && remotePaths.has(normalizeRemotePath(project.remotePath));
|
|
@@ -429,7 +429,7 @@ function cleanupCodexStateContainer(container, hostId, remotePaths) {
|
|
|
429
429
|
}
|
|
430
430
|
const managedConnections = container["codex-managed-remote-connections"];
|
|
431
431
|
if (Array.isArray(managedConnections)) {
|
|
432
|
-
container["codex-managed-remote-connections"] = managedConnections.filter((connection) => !isRecord
|
|
432
|
+
container["codex-managed-remote-connections"] = managedConnections.filter((connection) => !isRecord(connection) || connection.hostId !== hostId);
|
|
433
433
|
}
|
|
434
434
|
for (const key of [
|
|
435
435
|
"remote-connection-analytics-id-by-host-id",
|
|
@@ -453,7 +453,7 @@ function cleanupProjectReferences(state, atomState, removedProjectIds) {
|
|
|
453
453
|
for (const projectId of removedProjectIds) {
|
|
454
454
|
removeObjectKey(state["sidebar-collapsed-groups"], projectId);
|
|
455
455
|
}
|
|
456
|
-
if (isRecord
|
|
456
|
+
if (isRecord(atomState)) {
|
|
457
457
|
cleanupStringArrayField(atomState, "project-order", removedProjectIds);
|
|
458
458
|
for (const projectId of removedProjectIds) {
|
|
459
459
|
removeObjectKey(atomState["sidebar-collapsed-groups"], projectId);
|
|
@@ -466,7 +466,7 @@ function removeCodexGlobalStateProject(state, entry, options = {}) {
|
|
|
466
466
|
const nextState = cloneJsonObject(state);
|
|
467
467
|
const removedProjectIds = cleanupCodexStateContainer(nextState, hostId, remotePaths);
|
|
468
468
|
const atomState = nextState["electron-persisted-atom-state"];
|
|
469
|
-
if (isRecord
|
|
469
|
+
if (isRecord(atomState)) {
|
|
470
470
|
for (const projectId of cleanupCodexStateContainer(atomState, hostId, remotePaths)) {
|
|
471
471
|
removedProjectIds.add(projectId);
|
|
472
472
|
}
|
|
@@ -482,7 +482,7 @@ function normalizeCodexStateContainer(container, hostId, canonicalRemotePath, le
|
|
|
482
482
|
}
|
|
483
483
|
let matchedProject = false;
|
|
484
484
|
container["remote-projects"] = remoteProjects.flatMap((project) => {
|
|
485
|
-
if (!isRecord
|
|
485
|
+
if (!isRecord(project) || project.hostId !== hostId || typeof project.remotePath !== "string") {
|
|
486
486
|
return [project];
|
|
487
487
|
}
|
|
488
488
|
const remotePath = normalizeRemotePath(project.remotePath);
|
|
@@ -510,7 +510,7 @@ function normalizeCodexGlobalStateProject(state, entry, options = {}) {
|
|
|
510
510
|
const nextState = cloneJsonObject(state);
|
|
511
511
|
const removedProjectIds = normalizeCodexStateContainer(nextState, hostId, canonicalRemotePath, legacyRemotePaths);
|
|
512
512
|
const atomState = nextState["electron-persisted-atom-state"];
|
|
513
|
-
if (isRecord
|
|
513
|
+
if (isRecord(atomState)) {
|
|
514
514
|
for (const projectId of normalizeCodexStateContainer(atomState, hostId, canonicalRemotePath, legacyRemotePaths)) {
|
|
515
515
|
removedProjectIds.add(projectId);
|
|
516
516
|
}
|
|
@@ -608,7 +608,7 @@ function uninstallCodexGlobalStateProject(entry, options = {}) {
|
|
|
608
608
|
}
|
|
609
609
|
const existingJson = readFileSync(statePath, "utf8");
|
|
610
610
|
const existingState = JSON.parse(existingJson);
|
|
611
|
-
if (!isRecord
|
|
611
|
+
if (!isRecord(existingState)) {
|
|
612
612
|
throw new Error(`Invalid Codex global state JSON: ${statePath}`);
|
|
613
613
|
}
|
|
614
614
|
const nextState = removeCodexGlobalStateProject(existingState, entry, { additionalRemotePaths: options.additionalRemotePaths });
|
|
@@ -638,7 +638,7 @@ function installCodexGlobalStateProject(entry, options = {}) {
|
|
|
638
638
|
}
|
|
639
639
|
const existingJson = readFileSync(statePath, "utf8");
|
|
640
640
|
const existingState = JSON.parse(existingJson);
|
|
641
|
-
if (!isRecord
|
|
641
|
+
if (!isRecord(existingState)) {
|
|
642
642
|
throw new Error(`Invalid Codex global state JSON: ${statePath}`);
|
|
643
643
|
}
|
|
644
644
|
const nextState = normalizeCodexGlobalStateProject(existingState, entry, { legacyRemotePaths: options.legacyRemotePaths });
|
|
@@ -691,6 +691,13 @@ const BOXDOWN_CONTAINER_AGENTS_DIR = "/home/node/.agents";
|
|
|
691
691
|
const BOXDOWN_CONTAINER_GITCONFIG_PATH = "/home/node/.gitconfig";
|
|
692
692
|
const BOXDOWN_CONTAINER_CODEX_DIR = "/home/node/.codex";
|
|
693
693
|
const BOXDOWN_CONTAINER_CODEX_AUTH_PATH = `${BOXDOWN_CONTAINER_CODEX_DIR}/auth.json`;
|
|
694
|
+
const BOXDOWN_CONTAINER_SECRET_ENV_DIR = "/run/boxdown/secrets";
|
|
695
|
+
const BOXDOWN_CONTAINER_SECRET_ENV_BOOTSTRAP = `${BOXDOWN_CONTAINER_DEVCONTAINER_DIR}/utils/secret-env-bootstrap.sh`;
|
|
696
|
+
const BOXDOWN_SECRET_ENV_NAMES = [
|
|
697
|
+
"ANTHROPIC_API_KEY",
|
|
698
|
+
"SNYK_TOKEN",
|
|
699
|
+
"OP_SERVICE_ACCOUNT_TOKEN"
|
|
700
|
+
];
|
|
694
701
|
const PACKAGE_NAME = "boxdown";
|
|
695
702
|
|
|
696
703
|
//#endregion
|
|
@@ -844,10 +851,14 @@ function interactiveTermSetupScript() {
|
|
|
844
851
|
function interactiveAgentPathScript() {
|
|
845
852
|
return ["codex_home=\"${CODEX_HOME:-${HOME}/.codex}\"", "export PATH=\"${HOME}/.local/bin:${HOME}/.opencode/bin:${codex_home}/packages/standalone/current/bin:${PATH}\""].join("\n");
|
|
846
853
|
}
|
|
854
|
+
function runtimeSecretEnvironmentScript() {
|
|
855
|
+
return "source \"${BASH_ENV:-/opt/boxdown/devcontainer/utils/secret-env-bootstrap.sh}\"";
|
|
856
|
+
}
|
|
847
857
|
function interactiveShellScript() {
|
|
848
858
|
return [
|
|
849
859
|
interactiveTermSetupScript(),
|
|
850
860
|
interactiveTtySetupScript(),
|
|
861
|
+
runtimeSecretEnvironmentScript(),
|
|
851
862
|
"exec bash -i"
|
|
852
863
|
].join("\n");
|
|
853
864
|
}
|
|
@@ -856,6 +867,7 @@ function interactiveCommandScript() {
|
|
|
856
867
|
interactiveTermSetupScript(),
|
|
857
868
|
interactiveTtySetupScript(),
|
|
858
869
|
interactiveAgentPathScript(),
|
|
870
|
+
runtimeSecretEnvironmentScript(),
|
|
859
871
|
"exec \"$@\""
|
|
860
872
|
].join("\n");
|
|
861
873
|
}
|
|
@@ -892,7 +904,8 @@ function buildGeneratedDevcontainerConfig(context, signing) {
|
|
|
892
904
|
const boxdownMounts = [
|
|
893
905
|
`type=bind,source=${context.assetsDevcontainerDir},target=${BOXDOWN_CONTAINER_DEVCONTAINER_DIR},readonly`,
|
|
894
906
|
`type=bind,source=${context.sshPublicKeyRuntimeDir},target=${BOXDOWN_CONTAINER_SSH_DIR},readonly`,
|
|
895
|
-
`type=bind,source=${context.hostGitconfigSnapshotDir},target=${BOXDOWN_CONTAINER_HOST_GITCONFIG_DIR},readonly
|
|
907
|
+
`type=bind,source=${context.hostGitconfigSnapshotDir},target=${BOXDOWN_CONTAINER_HOST_GITCONFIG_DIR},readonly`,
|
|
908
|
+
`type=bind,source=${context.workspaceSecretEnvDir},target=${BOXDOWN_CONTAINER_SECRET_ENV_DIR},readonly`
|
|
896
909
|
];
|
|
897
910
|
if (directoryExists(context.hostAgentsDir) && !hasMountTarget(mounts, "/home/node/.agents")) {
|
|
898
911
|
boxdownMounts.push(`type=bind,source=${context.hostAgentsDir},target=${BOXDOWN_CONTAINER_AGENTS_DIR},readonly`);
|
|
@@ -912,6 +925,7 @@ function buildGeneratedDevcontainerConfig(context, signing) {
|
|
|
912
925
|
`BOXDOWN_WORKSPACE_FOLDER=${shellQuote(context.workspaceFolder)}`,
|
|
913
926
|
`BOXDOWN_HOST_GITCONFIG_PATH=${shellQuote(context.hostGitconfigPath)}`,
|
|
914
927
|
`BOXDOWN_HOST_GITCONFIG_SNAPSHOT_PATH=${shellQuote(context.hostGitconfigSnapshotPath)}`,
|
|
928
|
+
`BOXDOWN_SECRET_ENV_DIR=${shellQuote(context.workspaceSecretEnvDir)}`,
|
|
915
929
|
`BOXDOWN_PROGRESS=${shellQuote("${localEnv:BOXDOWN_PROGRESS}")}`,
|
|
916
930
|
`BOXDOWN_VERBOSE=${shellQuote("${localEnv:BOXDOWN_VERBOSE}")}`,
|
|
917
931
|
"bash",
|
|
@@ -933,10 +947,16 @@ function buildGeneratedDevcontainerConfig(context, signing) {
|
|
|
933
947
|
...baseConfig.containerEnv ?? {},
|
|
934
948
|
BOXDOWN_CONTAINER_WORKSPACE_FOLDER: "/workspaces/${localWorkspaceFolderBasename}",
|
|
935
949
|
BOXDOWN_WORKSPACE_BASENAME: "${localWorkspaceFolderBasename}",
|
|
950
|
+
BOXDOWN_SECRET_ENV_DIR: BOXDOWN_CONTAINER_SECRET_ENV_DIR,
|
|
951
|
+
BASH_ENV: BOXDOWN_CONTAINER_SECRET_ENV_BOOTSTRAP,
|
|
936
952
|
DEVCONTAINER_SSH_PUBLIC_KEY_FILE: BOXDOWN_CONTAINER_SSH_PUBLIC_KEY_PATH,
|
|
937
953
|
BOXDOWN_GIT_SIGNING_ENABLED: signing?.enabled === true ? "1" : "0",
|
|
938
954
|
BOXDOWN_GIT_SIGNING_KEY_PATH: "/opt/boxdown/state/git-signing/signing-key.pub",
|
|
939
|
-
...signing?.enabled ===
|
|
955
|
+
...signing?.enabled === false && signing.reason !== undefined ? { BOXDOWN_GIT_SIGNING_REASON: signing.reason } : {},
|
|
956
|
+
...signing?.enabled === true ? {
|
|
957
|
+
BOXDOWN_GIT_SIGNING_SOURCE_SOCKET: "/run/boxdown/ssh-agent.sock",
|
|
958
|
+
SSH_AUTH_SOCK: "/run/boxdown/ssh-agent-node.sock"
|
|
959
|
+
} : {}
|
|
940
960
|
}
|
|
941
961
|
};
|
|
942
962
|
}
|
|
@@ -1116,6 +1136,246 @@ function runInteractive(command, args, options = {}) {
|
|
|
1116
1136
|
});
|
|
1117
1137
|
}
|
|
1118
1138
|
|
|
1139
|
+
//#endregion
|
|
1140
|
+
//#region src/git-signing.ts
|
|
1141
|
+
const GIT_SIGNING_REASON_MESSAGES = {
|
|
1142
|
+
"agent-unavailable": "the host SSH agent is unavailable",
|
|
1143
|
+
"no-identities": "the host SSH agent has no loaded identities",
|
|
1144
|
+
"ambiguous-identities": "multiple SSH identities are loaded and no signing key could be selected safely",
|
|
1145
|
+
"configured-key-unreadable": "the configured SSH signing-key file could not be read",
|
|
1146
|
+
"configured-key-invalid": "the configured SSH signing key is not a valid public key",
|
|
1147
|
+
"configured-key-not-loaded": "the configured SSH signing key is not loaded in the agent",
|
|
1148
|
+
"agent-socket-unavailable": "the host SSH-agent socket is unavailable",
|
|
1149
|
+
"docker-probe-image-unavailable": "no local Docker image is available to probe the SSH-agent mount",
|
|
1150
|
+
"agent-mount-unavailable": "Docker could not mount the host SSH-agent socket"
|
|
1151
|
+
};
|
|
1152
|
+
function compactDiagnosticDetail(detail) {
|
|
1153
|
+
return detail.trim().replace(/\s+/gu, " ").slice(0, 2e3).replace(/-----BEGIN [^-]*PRIVATE KEY-----.*?(?:-----END [^-]*PRIVATE KEY-----|$)/giu, "[redacted-private-key]").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]").replace(/\b(?:github_pat_|gh[pousr]_|glpat-|xox[baprs]-)[A-Za-z0-9_-]{8,}/gu, "[redacted-token]").replace(/\b(?:Bearer\s+|token[=:]\s*)[A-Za-z0-9._~+/-]{8,}/giu, "[redacted-token]").slice(0, 300);
|
|
1154
|
+
}
|
|
1155
|
+
function reportGitSigningPlan(plan, options = {}) {
|
|
1156
|
+
if (plan.enabled) return;
|
|
1157
|
+
const reason = plan.reason ?? "agent-unavailable";
|
|
1158
|
+
const detail = plan.detail === undefined ? "" : ` detail=${compactDiagnosticDetail(plan.detail)}`;
|
|
1159
|
+
options.logger?.boxdown(`git-signing: enabled=false reason=${reason}${detail}\n`);
|
|
1160
|
+
if (options.quiet !== true) {
|
|
1161
|
+
const writeWarning = options.writeWarning ?? ((message) => process.stderr.write(message));
|
|
1162
|
+
writeWarning(`boxdown: commit signing disabled: ${GIT_SIGNING_REASON_MESSAGES[reason]}; commits will remain unsigned.\n`);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
function parseSshPublicKey(value) {
|
|
1166
|
+
const [algorithm, key] = value.trim().split(/\s+/, 3);
|
|
1167
|
+
if (algorithm === undefined || key === undefined || !/^ssh-[A-Za-z0-9-]+$/.test(algorithm) || key.length === 0) {
|
|
1168
|
+
return undefined;
|
|
1169
|
+
}
|
|
1170
|
+
return `${algorithm} ${key}`;
|
|
1171
|
+
}
|
|
1172
|
+
function resolveConfiguredSshSigningKey(value, options) {
|
|
1173
|
+
const inlineValue = value.startsWith("key::") ? value.slice("key::".length) : value;
|
|
1174
|
+
const inlineKey = parseSshPublicKey(inlineValue);
|
|
1175
|
+
if (inlineKey !== undefined) {
|
|
1176
|
+
return { key: inlineKey };
|
|
1177
|
+
}
|
|
1178
|
+
if (value.startsWith("key::")) {
|
|
1179
|
+
return {
|
|
1180
|
+
reason: "configured-key-invalid",
|
|
1181
|
+
detail: "configured inline value is not a valid SSH public key"
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
let keyPath;
|
|
1185
|
+
if (value.startsWith("~/")) {
|
|
1186
|
+
if (options.homeDir === undefined) {
|
|
1187
|
+
return {
|
|
1188
|
+
reason: "configured-key-unreadable",
|
|
1189
|
+
detail: "configured public-key file could not be read"
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
keyPath = join(options.homeDir, value.slice(2));
|
|
1193
|
+
} else {
|
|
1194
|
+
keyPath = isAbsolute(value) ? value : resolve(options.workspaceFolder, value);
|
|
1195
|
+
}
|
|
1196
|
+
let publicKeyText;
|
|
1197
|
+
try {
|
|
1198
|
+
publicKeyText = readFileSync(keyPath, "utf8");
|
|
1199
|
+
} catch {
|
|
1200
|
+
return {
|
|
1201
|
+
reason: "configured-key-unreadable",
|
|
1202
|
+
detail: "configured public-key file could not be read"
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
const publicKey = parseSshPublicKey(publicKeyText.split(/\r?\n/u).find((line) => line.trim().length > 0) ?? "");
|
|
1206
|
+
return publicKey === undefined ? {
|
|
1207
|
+
reason: "configured-key-invalid",
|
|
1208
|
+
detail: "configured public-key file does not contain a valid SSH public key"
|
|
1209
|
+
} : { key: publicKey };
|
|
1210
|
+
}
|
|
1211
|
+
function selectGitSigningKey(identities, configuredKey, githubKeys) {
|
|
1212
|
+
const keys = identities.map(parseSshPublicKey).filter((key) => key !== undefined);
|
|
1213
|
+
if (keys.length === 0) return { reason: "no-identities" };
|
|
1214
|
+
const configured = configuredKey === undefined ? undefined : parseSshPublicKey(configuredKey);
|
|
1215
|
+
if (configuredKey !== undefined && configured === undefined) {
|
|
1216
|
+
return { reason: "configured-key-invalid" };
|
|
1217
|
+
}
|
|
1218
|
+
if (configured !== undefined) {
|
|
1219
|
+
return keys.includes(configured) ? { key: configured } : { reason: "configured-key-not-loaded" };
|
|
1220
|
+
}
|
|
1221
|
+
const github = new Set((githubKeys ?? []).map(parseSshPublicKey).filter((key) => key !== undefined));
|
|
1222
|
+
const matches = keys.filter((key) => github.has(key));
|
|
1223
|
+
if (matches.length === 1) return { key: matches[0] };
|
|
1224
|
+
if (keys.length === 1) return { key: keys[0] };
|
|
1225
|
+
return { reason: "ambiguous-identities" };
|
|
1226
|
+
}
|
|
1227
|
+
function writeGitSigningPublicKey(context, key) {
|
|
1228
|
+
mkdirSync(context.gitSigningStateDir, {
|
|
1229
|
+
recursive: true,
|
|
1230
|
+
mode: 448
|
|
1231
|
+
});
|
|
1232
|
+
writeFileSync(join(context.gitSigningStateDir, "signing-key.pub"), `${parseSshPublicKey(key) ?? key}\n`, { mode: 420 });
|
|
1233
|
+
}
|
|
1234
|
+
async function runGitSigningCommand(command, args) {
|
|
1235
|
+
return runBuffered(command, args, {
|
|
1236
|
+
mirrorStdout: false,
|
|
1237
|
+
mirrorStderr: false
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
function failedCommandDetail(label, result) {
|
|
1241
|
+
const stderr = compactDiagnosticDetail(result.stderr);
|
|
1242
|
+
return stderr.length === 0 ? `${label} failed with exit code ${result.code}` : `${label} failed with exit code ${result.code}: ${stderr}`;
|
|
1243
|
+
}
|
|
1244
|
+
async function probeDockerAgentMount(source, runCommand) {
|
|
1245
|
+
const images = await runCommand("docker", [
|
|
1246
|
+
"image",
|
|
1247
|
+
"ls",
|
|
1248
|
+
"--format",
|
|
1249
|
+
"{{.Repository}}:{{.Tag}}"
|
|
1250
|
+
]);
|
|
1251
|
+
const image = images.stdout.split(/\r?\n/).map((value) => value.trim()).find((value) => value.length > 0 && value !== "<none>:<none>");
|
|
1252
|
+
if (images.code !== 0) {
|
|
1253
|
+
return {
|
|
1254
|
+
ok: false,
|
|
1255
|
+
reason: "agent-mount-unavailable",
|
|
1256
|
+
detail: failedCommandDetail("Docker image listing", images)
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
if (image === undefined) {
|
|
1260
|
+
return {
|
|
1261
|
+
ok: false,
|
|
1262
|
+
reason: "docker-probe-image-unavailable",
|
|
1263
|
+
detail: "no tagged local Docker image was found"
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
const created = await runCommand("docker", [
|
|
1267
|
+
"create",
|
|
1268
|
+
"--pull=never",
|
|
1269
|
+
"--entrypoint",
|
|
1270
|
+
"/bin/true",
|
|
1271
|
+
"--mount",
|
|
1272
|
+
`type=bind,source=${source},target=/run/boxdown/ssh-agent.sock`,
|
|
1273
|
+
image
|
|
1274
|
+
]);
|
|
1275
|
+
const containerId = created.stdout.trim().split(/\r?\n/)[0];
|
|
1276
|
+
if (created.code !== 0 || containerId === undefined || containerId.length === 0) {
|
|
1277
|
+
return {
|
|
1278
|
+
ok: false,
|
|
1279
|
+
reason: "agent-mount-unavailable",
|
|
1280
|
+
detail: failedCommandDetail("Docker SSH-agent mount probe", created)
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
await runCommand("docker", [
|
|
1284
|
+
"rm",
|
|
1285
|
+
"-f",
|
|
1286
|
+
containerId
|
|
1287
|
+
]);
|
|
1288
|
+
return { ok: true };
|
|
1289
|
+
}
|
|
1290
|
+
async function resolveGitSigningPlan(context, options = {}) {
|
|
1291
|
+
const runCommand = options.runCommand ?? runGitSigningCommand;
|
|
1292
|
+
const result = await runCommand("ssh-add", ["-L"]);
|
|
1293
|
+
if (result.code !== 0) {
|
|
1294
|
+
return {
|
|
1295
|
+
enabled: false,
|
|
1296
|
+
reason: "agent-unavailable",
|
|
1297
|
+
detail: failedCommandDetail("ssh-add -L", result)
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
const identities = result.stdout.split(/\r?\n/);
|
|
1301
|
+
const format = await runCommand("git", [
|
|
1302
|
+
"config",
|
|
1303
|
+
"--global",
|
|
1304
|
+
"--get",
|
|
1305
|
+
"gpg.format"
|
|
1306
|
+
]);
|
|
1307
|
+
let configuredKey;
|
|
1308
|
+
if (format.code === 0 && format.stdout.trim() === "ssh") {
|
|
1309
|
+
const signingKey = await runCommand("git", [
|
|
1310
|
+
"config",
|
|
1311
|
+
"--global",
|
|
1312
|
+
"--get",
|
|
1313
|
+
"user.signingkey"
|
|
1314
|
+
]);
|
|
1315
|
+
if (signingKey.code === 0 && signingKey.stdout.trim().length > 0) {
|
|
1316
|
+
const resolvedKey = resolveConfiguredSshSigningKey(signingKey.stdout.trim(), {
|
|
1317
|
+
homeDir: options.env?.HOME ?? process.env.HOME,
|
|
1318
|
+
workspaceFolder: context.workspaceFolder
|
|
1319
|
+
});
|
|
1320
|
+
if (resolvedKey.key === undefined) {
|
|
1321
|
+
return {
|
|
1322
|
+
enabled: false,
|
|
1323
|
+
reason: resolvedKey.reason ?? "configured-key-invalid",
|
|
1324
|
+
detail: resolvedKey.detail
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
configuredKey = resolvedKey.key;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
let githubKeys;
|
|
1331
|
+
if (configuredKey === undefined && identities.filter((key) => parseSshPublicKey(key) !== undefined).length > 1) {
|
|
1332
|
+
const user = await runCommand("gh", [
|
|
1333
|
+
"api",
|
|
1334
|
+
"user",
|
|
1335
|
+
"--jq",
|
|
1336
|
+
".login"
|
|
1337
|
+
]);
|
|
1338
|
+
const login = user.stdout.trim();
|
|
1339
|
+
if (user.code === 0 && login.length > 0) {
|
|
1340
|
+
const github = await runCommand("gh", [
|
|
1341
|
+
"api",
|
|
1342
|
+
`users/${login}/keys`,
|
|
1343
|
+
"--paginate",
|
|
1344
|
+
"--jq",
|
|
1345
|
+
".[].key"
|
|
1346
|
+
]);
|
|
1347
|
+
if (github.code === 0) githubKeys = github.stdout.split(/\r?\n/);
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
const selected = selectGitSigningKey(identities, configuredKey, githubKeys);
|
|
1351
|
+
if (selected.key === undefined) return {
|
|
1352
|
+
enabled: false,
|
|
1353
|
+
reason: selected.reason
|
|
1354
|
+
};
|
|
1355
|
+
const agentSocketSource = (options.platform ?? process.platform) === "darwin" ? "/run/host-services/ssh-auth.sock" : options.env?.SSH_AUTH_SOCK ?? process.env.SSH_AUTH_SOCK;
|
|
1356
|
+
if (agentSocketSource === undefined || agentSocketSource.length === 0) {
|
|
1357
|
+
return {
|
|
1358
|
+
enabled: false,
|
|
1359
|
+
reason: "agent-socket-unavailable",
|
|
1360
|
+
detail: "SSH_AUTH_SOCK is not set"
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
const mountProbe = await probeDockerAgentMount(agentSocketSource, runCommand);
|
|
1364
|
+
if (!mountProbe.ok) {
|
|
1365
|
+
return {
|
|
1366
|
+
enabled: false,
|
|
1367
|
+
reason: mountProbe.reason,
|
|
1368
|
+
detail: mountProbe.detail
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
writeGitSigningPublicKey(context, selected.key);
|
|
1372
|
+
return {
|
|
1373
|
+
enabled: true,
|
|
1374
|
+
publicKey: selected.key,
|
|
1375
|
+
agentSocketSource
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1119
1379
|
//#endregion
|
|
1120
1380
|
//#region src/doctor.ts
|
|
1121
1381
|
function nodeVersionPasses(version) {
|
|
@@ -1129,6 +1389,26 @@ function check(name, pass, okMessage, failMessage) {
|
|
|
1129
1389
|
message: pass ? okMessage : failMessage
|
|
1130
1390
|
};
|
|
1131
1391
|
}
|
|
1392
|
+
function secretEnvironmentConfigCheck(context) {
|
|
1393
|
+
let config;
|
|
1394
|
+
try {
|
|
1395
|
+
config = existsSync(context.generatedConfigPath) ? JSON.parse(readFileSync(context.generatedConfigPath, "utf8")) : buildGeneratedDevcontainerConfig(context);
|
|
1396
|
+
} catch {
|
|
1397
|
+
return {
|
|
1398
|
+
name: "secret-environment-config",
|
|
1399
|
+
level: "warn",
|
|
1400
|
+
message: "Generated config could not be checked for secret-safe environment handling"
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
const runArgs = Array.isArray(config.runArgs) ? config.runArgs : [];
|
|
1404
|
+
const containerEnv = config.containerEnv ?? {};
|
|
1405
|
+
const unsafe = runArgs.includes("--env-file") || runArgs.some((arg) => arg.includes(".env.development")) || BOXDOWN_SECRET_ENV_NAMES.some((name) => Object.hasOwn(containerEnv, name));
|
|
1406
|
+
return {
|
|
1407
|
+
name: "secret-environment-config",
|
|
1408
|
+
level: unsafe ? "warn" : "ok",
|
|
1409
|
+
message: unsafe ? "Generated config still exposes Boxdown secrets through Docker environment settings; recreate after upgrading Boxdown" : "Generated config uses runtime-mounted secrets without Docker environment values"
|
|
1410
|
+
};
|
|
1411
|
+
}
|
|
1132
1412
|
async function runDoctorCommand(command, args) {
|
|
1133
1413
|
return runBuffered(command, args, {
|
|
1134
1414
|
mirrorStdout: false,
|
|
@@ -1149,11 +1429,91 @@ async function runDoctorChecks(context, options = {}) {
|
|
|
1149
1429
|
const nodeVersion = process.versions.node;
|
|
1150
1430
|
checks.push(check("node", nodeVersionPasses(nodeVersion), `Node ${nodeVersion}`, `Node ${nodeVersion}; expected >=24.0.0`));
|
|
1151
1431
|
const sshAgent = await runCommand("ssh-add", ["-L"]);
|
|
1152
|
-
const
|
|
1432
|
+
const identityLines = sshAgent.code === 0 ? sshAgent.stdout.split(/\r?\n/).filter((line) => line.trim().startsWith("ssh-")) : [];
|
|
1433
|
+
const identities = identityLines.length;
|
|
1434
|
+
let configuredKey;
|
|
1435
|
+
let configuredFailure;
|
|
1436
|
+
const format = await runCommand("git", [
|
|
1437
|
+
"config",
|
|
1438
|
+
"--global",
|
|
1439
|
+
"--get",
|
|
1440
|
+
"gpg.format"
|
|
1441
|
+
]);
|
|
1442
|
+
if (format.code === 0 && format.stdout.trim() === "ssh") {
|
|
1443
|
+
const signingKey = await runCommand("git", [
|
|
1444
|
+
"config",
|
|
1445
|
+
"--global",
|
|
1446
|
+
"--get",
|
|
1447
|
+
"user.signingkey"
|
|
1448
|
+
]);
|
|
1449
|
+
if (signingKey.code === 0 && signingKey.stdout.trim().length > 0) {
|
|
1450
|
+
const resolved = resolveConfiguredSshSigningKey(signingKey.stdout.trim(), {
|
|
1451
|
+
homeDir: dirname(context.hostGitconfigPath),
|
|
1452
|
+
workspaceFolder: context.workspaceFolder
|
|
1453
|
+
});
|
|
1454
|
+
if (resolved.key === undefined) {
|
|
1455
|
+
configuredFailure = {
|
|
1456
|
+
reason: resolved.reason ?? "configured-key-invalid",
|
|
1457
|
+
detail: resolved.detail
|
|
1458
|
+
};
|
|
1459
|
+
} else {
|
|
1460
|
+
configuredKey = resolved.key;
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
const includeOptional = options.includeOptional ?? true;
|
|
1465
|
+
let ghAvailable = false;
|
|
1466
|
+
let ghAuth = false;
|
|
1467
|
+
let githubLogin;
|
|
1468
|
+
let githubAuthKeys;
|
|
1469
|
+
if (includeOptional) {
|
|
1470
|
+
ghAvailable = await commandWorks(runCommand, "gh", ["--version"]);
|
|
1471
|
+
if (ghAvailable) {
|
|
1472
|
+
ghAuth = await commandWorks(runCommand, "gh", [
|
|
1473
|
+
"auth",
|
|
1474
|
+
"status",
|
|
1475
|
+
"--hostname",
|
|
1476
|
+
"github.com"
|
|
1477
|
+
]);
|
|
1478
|
+
if (ghAuth && configuredKey === undefined && configuredFailure === undefined && identities > 1) {
|
|
1479
|
+
const user = await runCommand("gh", [
|
|
1480
|
+
"api",
|
|
1481
|
+
"user",
|
|
1482
|
+
"--jq",
|
|
1483
|
+
".login"
|
|
1484
|
+
]);
|
|
1485
|
+
githubLogin = user.code === 0 && user.stdout.trim().length > 0 ? user.stdout.trim() : undefined;
|
|
1486
|
+
if (githubLogin !== undefined) {
|
|
1487
|
+
const authentication = await runCommand("gh", [
|
|
1488
|
+
"api",
|
|
1489
|
+
`users/${githubLogin}/keys`,
|
|
1490
|
+
"--paginate",
|
|
1491
|
+
"--jq",
|
|
1492
|
+
".[].key"
|
|
1493
|
+
]);
|
|
1494
|
+
if (authentication.code === 0) githubAuthKeys = authentication.stdout.split(/\r?\n/);
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
const selected = configuredFailure ?? selectGitSigningKey(identityLines, configuredKey, githubAuthKeys);
|
|
1500
|
+
const selectedByConfiguration = selected.key !== undefined && configuredKey !== undefined;
|
|
1501
|
+
const selectedByGithub = selected.key !== undefined && configuredKey === undefined && identities > 1;
|
|
1502
|
+
const signingMessages = {
|
|
1503
|
+
"agent-unavailable": "SSH agent is unavailable; Boxdown commits will remain unsigned",
|
|
1504
|
+
"no-identities": "SSH agent has no identities; Boxdown commits will remain unsigned",
|
|
1505
|
+
"ambiguous-identities": "SSH agent has multiple identities; Boxdown will not guess a signing key and commits will remain unsigned",
|
|
1506
|
+
"configured-key-unreadable": "Configured SSH signing-key file could not be read; Boxdown commits will remain unsigned",
|
|
1507
|
+
"configured-key-invalid": "Configured SSH signing key is not a valid public key; Boxdown commits will remain unsigned",
|
|
1508
|
+
"configured-key-not-loaded": "Configured SSH signing key is not loaded in the agent; Boxdown commits will remain unsigned",
|
|
1509
|
+
"agent-socket-unavailable": "Host SSH-agent socket is unavailable; Boxdown commits will remain unsigned",
|
|
1510
|
+
"docker-probe-image-unavailable": "No local Docker image is available to probe commit-signing agent forwarding",
|
|
1511
|
+
"agent-mount-unavailable": "Docker could not mount the host SSH-agent socket; Boxdown commits will remain unsigned"
|
|
1512
|
+
};
|
|
1153
1513
|
checks.push({
|
|
1154
1514
|
name: "git-signing-agent",
|
|
1155
|
-
level: sshAgent.code === 0 &&
|
|
1156
|
-
message: sshAgent.code !== 0 ? "
|
|
1515
|
+
level: sshAgent.code === 0 && selected.key !== undefined ? "ok" : "warn",
|
|
1516
|
+
message: sshAgent.code !== 0 ? signingMessages["agent-unavailable"] : selected.key === undefined ? signingMessages[selected.reason ?? "ambiguous-identities"] : selectedByConfiguration ? "Configured SSH signing key is loaded in the agent" : selectedByGithub ? "GitHub authentication keys identify one SSH agent identity for Boxdown commit signing" : "One SSH agent identity is available for Boxdown commit signing"
|
|
1157
1517
|
});
|
|
1158
1518
|
checks.push(check("devcontainers-cli", await packagedDevcontainerCliWorks(context, runCommand), "Packaged @devcontainers/cli is available", "Packaged @devcontainers/cli is required but was not available"));
|
|
1159
1519
|
const dockerCliWorks = await commandWorks(runCommand, "docker", ["--version"]);
|
|
@@ -1166,29 +1526,27 @@ async function runDoctorChecks(context, options = {}) {
|
|
|
1166
1526
|
}
|
|
1167
1527
|
checks.push(check("ssh-keygen", await commandExists(runCommand, "ssh-keygen", ["-?"]), "ssh-keygen is available", "ssh-keygen is required but was not available"));
|
|
1168
1528
|
checks.push(check("assets", existsSync(context.assetsDevcontainerDir), `Devcontainer assets found at ${context.assetsDevcontainerDir}`, `Missing Boxdown devcontainer assets: ${context.assetsDevcontainerDir}`));
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
"auth",
|
|
1173
|
-
"status",
|
|
1174
|
-
"--hostname",
|
|
1175
|
-
"github.com"
|
|
1176
|
-
]);
|
|
1529
|
+
checks.push(secretEnvironmentConfigCheck(context));
|
|
1530
|
+
if (includeOptional) {
|
|
1531
|
+
if (ghAvailable) {
|
|
1177
1532
|
checks.push({
|
|
1178
1533
|
name: "gh-auth",
|
|
1179
1534
|
level: ghAuth ? "ok" : "warn",
|
|
1180
1535
|
message: ghAuth ? "GitHub CLI auth is available" : "GitHub CLI is available but not authenticated"
|
|
1181
1536
|
});
|
|
1182
|
-
if (ghAuth &&
|
|
1183
|
-
|
|
1184
|
-
"
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1537
|
+
if (ghAuth && selected.key !== undefined) {
|
|
1538
|
+
if (githubLogin === undefined) {
|
|
1539
|
+
const user = await runCommand("gh", [
|
|
1540
|
+
"api",
|
|
1541
|
+
"user",
|
|
1542
|
+
"--jq",
|
|
1543
|
+
".login"
|
|
1544
|
+
]);
|
|
1545
|
+
githubLogin = user.code === 0 && user.stdout.trim().length > 0 ? user.stdout.trim() : undefined;
|
|
1546
|
+
}
|
|
1547
|
+
const signing = githubLogin !== undefined ? await runCommand("gh", [
|
|
1190
1548
|
"api",
|
|
1191
|
-
`users/${
|
|
1549
|
+
`users/${githubLogin}/ssh_signing_keys`,
|
|
1192
1550
|
"--paginate",
|
|
1193
1551
|
"--jq",
|
|
1194
1552
|
".[].key"
|
|
@@ -1197,11 +1555,10 @@ async function runDoctorChecks(context, options = {}) {
|
|
|
1197
1555
|
stdout: "",
|
|
1198
1556
|
stderr: ""
|
|
1199
1557
|
};
|
|
1200
|
-
const identity = sshAgent.stdout.split(/\r?\n/).find((line) => line.trim().startsWith("ssh-"))?.trim();
|
|
1201
1558
|
checks.push({
|
|
1202
1559
|
name: "git-signing-github",
|
|
1203
|
-
level: signing.code === 0 &&
|
|
1204
|
-
message: signing.code !== 0 ? "GitHub SSH signing-key registration could not be checked" : signing.stdout.includes(
|
|
1560
|
+
level: signing.code === 0 && signing.stdout.includes(selected.key) ? "ok" : "warn",
|
|
1561
|
+
message: signing.code !== 0 ? "GitHub SSH signing-key registration could not be checked" : signing.stdout.includes(selected.key) ? "Selected SSH key is registered with GitHub for commit signing" : "Register the selected public key with GitHub as a signing key to receive Verified badges"
|
|
1205
1562
|
});
|
|
1206
1563
|
}
|
|
1207
1564
|
} else {
|
|
@@ -1247,6 +1604,11 @@ async function checkDockerBindMounts(context, runCommand, dockerReady) {
|
|
|
1247
1604
|
}
|
|
1248
1605
|
mkdirSync(context.workspaceDataDir, { recursive: true });
|
|
1249
1606
|
const runtimeProbeDir = mkdtempSync(join(context.workspaceDataDir, "doctor-mount-probe-"));
|
|
1607
|
+
mkdirSync(context.workspaceSecretEnvDir, {
|
|
1608
|
+
recursive: true,
|
|
1609
|
+
mode: 448
|
|
1610
|
+
});
|
|
1611
|
+
chmodSync(context.workspaceSecretEnvDir, 448);
|
|
1250
1612
|
const sources = [
|
|
1251
1613
|
{
|
|
1252
1614
|
label: "workspace",
|
|
@@ -1259,6 +1621,10 @@ async function checkDockerBindMounts(context, runCommand, dockerReady) {
|
|
|
1259
1621
|
{
|
|
1260
1622
|
label: "Boxdown runtime state",
|
|
1261
1623
|
path: runtimeProbeDir
|
|
1624
|
+
},
|
|
1625
|
+
{
|
|
1626
|
+
label: "Boxdown runtime secret state",
|
|
1627
|
+
path: context.workspaceSecretEnvDir
|
|
1262
1628
|
}
|
|
1263
1629
|
];
|
|
1264
1630
|
try {
|
|
@@ -1491,147 +1857,6 @@ async function runWorkspaceGit(workspaceFolder, args) {
|
|
|
1491
1857
|
});
|
|
1492
1858
|
}
|
|
1493
1859
|
|
|
1494
|
-
//#endregion
|
|
1495
|
-
//#region src/git-signing.ts
|
|
1496
|
-
function parseSshPublicKey(value) {
|
|
1497
|
-
const [algorithm, key] = value.trim().split(/\s+/, 3);
|
|
1498
|
-
if (algorithm === undefined || key === undefined || !/^ssh-[A-Za-z0-9-]+$/.test(algorithm) || key.length === 0) {
|
|
1499
|
-
return undefined;
|
|
1500
|
-
}
|
|
1501
|
-
return `${algorithm} ${key}`;
|
|
1502
|
-
}
|
|
1503
|
-
function selectGitSigningKey(identities, configuredKey, githubKeys) {
|
|
1504
|
-
const keys = identities.map(parseSshPublicKey).filter((key) => key !== undefined);
|
|
1505
|
-
if (keys.length === 0) return { reason: "no-identities" };
|
|
1506
|
-
const configured = configuredKey === undefined ? undefined : parseSshPublicKey(configuredKey);
|
|
1507
|
-
if (configured !== undefined) {
|
|
1508
|
-
return keys.includes(configured) ? { key: configured } : { reason: "configured-key-not-loaded" };
|
|
1509
|
-
}
|
|
1510
|
-
const github = new Set((githubKeys ?? []).map(parseSshPublicKey).filter((key) => key !== undefined));
|
|
1511
|
-
const matches = keys.filter((key) => github.has(key));
|
|
1512
|
-
if (matches.length === 1) return { key: matches[0] };
|
|
1513
|
-
if (keys.length === 1) return { key: keys[0] };
|
|
1514
|
-
return { reason: "ambiguous-identities" };
|
|
1515
|
-
}
|
|
1516
|
-
function writeGitSigningPublicKey(context, key) {
|
|
1517
|
-
mkdirSync(context.gitSigningStateDir, {
|
|
1518
|
-
recursive: true,
|
|
1519
|
-
mode: 448
|
|
1520
|
-
});
|
|
1521
|
-
writeFileSync(join(context.gitSigningStateDir, "signing-key.pub"), `${parseSshPublicKey(key) ?? key}\n`, { mode: 420 });
|
|
1522
|
-
}
|
|
1523
|
-
async function dockerCanMountAgent(source) {
|
|
1524
|
-
const images = await runBuffered("docker", [
|
|
1525
|
-
"image",
|
|
1526
|
-
"ls",
|
|
1527
|
-
"--format",
|
|
1528
|
-
"{{.Repository}}:{{.Tag}}"
|
|
1529
|
-
], {
|
|
1530
|
-
mirrorStdout: false,
|
|
1531
|
-
mirrorStderr: false
|
|
1532
|
-
});
|
|
1533
|
-
const image = images.stdout.split(/\r?\n/).map((value) => value.trim()).find((value) => value.length > 0 && value !== "<none>:<none>");
|
|
1534
|
-
if (images.code !== 0 || image === undefined) return false;
|
|
1535
|
-
const created = await runBuffered("docker", [
|
|
1536
|
-
"create",
|
|
1537
|
-
"--pull=never",
|
|
1538
|
-
"--entrypoint",
|
|
1539
|
-
"/bin/true",
|
|
1540
|
-
"--mount",
|
|
1541
|
-
`type=bind,source=${source},target=/run/boxdown/ssh-agent.sock`,
|
|
1542
|
-
image
|
|
1543
|
-
], {
|
|
1544
|
-
mirrorStdout: false,
|
|
1545
|
-
mirrorStderr: false
|
|
1546
|
-
});
|
|
1547
|
-
const containerId = created.stdout.trim().split(/\r?\n/)[0];
|
|
1548
|
-
if (created.code !== 0 || containerId === undefined || containerId.length === 0) return false;
|
|
1549
|
-
await runBuffered("docker", [
|
|
1550
|
-
"rm",
|
|
1551
|
-
"-f",
|
|
1552
|
-
containerId
|
|
1553
|
-
], {
|
|
1554
|
-
mirrorStdout: false,
|
|
1555
|
-
mirrorStderr: false
|
|
1556
|
-
});
|
|
1557
|
-
return true;
|
|
1558
|
-
}
|
|
1559
|
-
async function resolveGitSigningPlan(context) {
|
|
1560
|
-
const result = await runBuffered("ssh-add", ["-L"], {
|
|
1561
|
-
mirrorStdout: false,
|
|
1562
|
-
mirrorStderr: false
|
|
1563
|
-
});
|
|
1564
|
-
if (result.code !== 0) return {
|
|
1565
|
-
enabled: false,
|
|
1566
|
-
reason: "agent-unavailable"
|
|
1567
|
-
};
|
|
1568
|
-
const identities = result.stdout.split(/\r?\n/);
|
|
1569
|
-
const format = await runBuffered("git", [
|
|
1570
|
-
"config",
|
|
1571
|
-
"--global",
|
|
1572
|
-
"--get",
|
|
1573
|
-
"gpg.format"
|
|
1574
|
-
], {
|
|
1575
|
-
mirrorStdout: false,
|
|
1576
|
-
mirrorStderr: false
|
|
1577
|
-
});
|
|
1578
|
-
const configuredKey = format.code === 0 && format.stdout.trim() === "ssh" ? (await runBuffered("git", [
|
|
1579
|
-
"config",
|
|
1580
|
-
"--global",
|
|
1581
|
-
"--get",
|
|
1582
|
-
"user.signingkey"
|
|
1583
|
-
], {
|
|
1584
|
-
mirrorStdout: false,
|
|
1585
|
-
mirrorStderr: false
|
|
1586
|
-
})).stdout.trim() : undefined;
|
|
1587
|
-
let githubKeys;
|
|
1588
|
-
if (identities.filter((key) => parseSshPublicKey(key) !== undefined).length > 1) {
|
|
1589
|
-
const user = await runBuffered("gh", [
|
|
1590
|
-
"api",
|
|
1591
|
-
"user",
|
|
1592
|
-
"--jq",
|
|
1593
|
-
".login"
|
|
1594
|
-
], {
|
|
1595
|
-
mirrorStdout: false,
|
|
1596
|
-
mirrorStderr: false
|
|
1597
|
-
});
|
|
1598
|
-
const login = user.stdout.trim();
|
|
1599
|
-
if (user.code === 0 && login.length > 0) {
|
|
1600
|
-
const github = await runBuffered("gh", [
|
|
1601
|
-
"api",
|
|
1602
|
-
`users/${login}/keys`,
|
|
1603
|
-
"--paginate",
|
|
1604
|
-
"--jq",
|
|
1605
|
-
".[].key"
|
|
1606
|
-
], {
|
|
1607
|
-
mirrorStdout: false,
|
|
1608
|
-
mirrorStderr: false
|
|
1609
|
-
});
|
|
1610
|
-
if (github.code === 0) githubKeys = github.stdout.split(/\r?\n/);
|
|
1611
|
-
}
|
|
1612
|
-
}
|
|
1613
|
-
const selected = selectGitSigningKey(identities, configuredKey, githubKeys);
|
|
1614
|
-
if (selected.key === undefined) return {
|
|
1615
|
-
enabled: false,
|
|
1616
|
-
reason: selected.reason
|
|
1617
|
-
};
|
|
1618
|
-
const agentSocketSource = process.platform === "darwin" ? "/run/host-services/ssh-auth.sock" : process.env.SSH_AUTH_SOCK;
|
|
1619
|
-
if (agentSocketSource === undefined || agentSocketSource.length === 0) return {
|
|
1620
|
-
enabled: false,
|
|
1621
|
-
reason: "agent-mount-unavailable"
|
|
1622
|
-
};
|
|
1623
|
-
if (!await dockerCanMountAgent(agentSocketSource)) return {
|
|
1624
|
-
enabled: false,
|
|
1625
|
-
reason: "agent-mount-unavailable"
|
|
1626
|
-
};
|
|
1627
|
-
writeGitSigningPublicKey(context, selected.key);
|
|
1628
|
-
return {
|
|
1629
|
-
enabled: true,
|
|
1630
|
-
publicKey: selected.key,
|
|
1631
|
-
agentSocketSource
|
|
1632
|
-
};
|
|
1633
|
-
}
|
|
1634
|
-
|
|
1635
1860
|
//#endregion
|
|
1636
1861
|
//#region src/metadata.ts
|
|
1637
1862
|
const WORKSPACE_METADATA_VERSION = 1;
|
|
@@ -2702,34 +2927,30 @@ async function findRunningContainerId(context, options = {}) {
|
|
|
2702
2927
|
}
|
|
2703
2928
|
return result.stdout.split(/\r?\n/).find((line) => line.length > 0);
|
|
2704
2929
|
}
|
|
2705
|
-
function isRecord(value) {
|
|
2706
|
-
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
2707
|
-
}
|
|
2708
2930
|
function parseDockerInspectImage(output, containerId) {
|
|
2709
|
-
const
|
|
2710
|
-
if (
|
|
2931
|
+
const [rawId, rawName] = output.trim().split("|");
|
|
2932
|
+
if (rawId === undefined || rawId.length === 0) {
|
|
2711
2933
|
return undefined;
|
|
2712
2934
|
}
|
|
2713
|
-
let
|
|
2935
|
+
let id;
|
|
2936
|
+
let name;
|
|
2714
2937
|
try {
|
|
2715
|
-
|
|
2938
|
+
id = JSON.parse(rawId);
|
|
2939
|
+
name = rawName === undefined || rawName.length === 0 ? undefined : JSON.parse(rawName);
|
|
2716
2940
|
} catch (error) {
|
|
2717
2941
|
throw new Error(`Could not parse docker inspect output for ${containerId}`, { cause: error });
|
|
2718
2942
|
}
|
|
2719
|
-
if (typeof
|
|
2720
|
-
return undefined;
|
|
2721
|
-
}
|
|
2722
|
-
const configImage = isRecord(parsed.Config) && typeof parsed.Config.Image === "string" && parsed.Config.Image.length > 0 ? parsed.Config.Image : undefined;
|
|
2943
|
+
if (typeof id !== "string" || id.length === 0) return undefined;
|
|
2723
2944
|
return {
|
|
2724
|
-
id
|
|
2725
|
-
...
|
|
2945
|
+
id,
|
|
2946
|
+
...typeof name === "string" && name.length > 0 ? { name } : {}
|
|
2726
2947
|
};
|
|
2727
2948
|
}
|
|
2728
2949
|
async function inspectContainerImage(containerId, options = {}) {
|
|
2729
2950
|
const result = await runBuffered("docker", [
|
|
2730
2951
|
"inspect",
|
|
2731
2952
|
"--format",
|
|
2732
|
-
"{{json .}}",
|
|
2953
|
+
"{{json .Image}}|{{json .Config.Image}}",
|
|
2733
2954
|
containerId
|
|
2734
2955
|
], {
|
|
2735
2956
|
logger: options.logger,
|
|
@@ -2851,7 +3072,15 @@ async function startDevcontainer(context, options = {}) {
|
|
|
2851
3072
|
progress.detail(context.generatedConfigPath);
|
|
2852
3073
|
}
|
|
2853
3074
|
try {
|
|
2854
|
-
|
|
3075
|
+
const signingPlan = await resolveGitSigningPlan(context);
|
|
3076
|
+
reportGitSigningPlan(signingPlan, {
|
|
3077
|
+
logger: options.logger,
|
|
3078
|
+
quiet: proxyMode,
|
|
3079
|
+
...progress === undefined ? {} : { writeWarning: (message) => {
|
|
3080
|
+
progress.warn(message.trimEnd());
|
|
3081
|
+
} }
|
|
3082
|
+
});
|
|
3083
|
+
writeGeneratedDevcontainerConfig(context, signingPlan);
|
|
2855
3084
|
if (hasConfigStep) {
|
|
2856
3085
|
progress?.completeStep("devcontainer-config");
|
|
2857
3086
|
}
|
|
@@ -3137,7 +3366,14 @@ async function refreshContainerGhAuth(context, options = {}) {
|
|
|
3137
3366
|
quiet: true,
|
|
3138
3367
|
progress
|
|
3139
3368
|
});
|
|
3140
|
-
|
|
3369
|
+
const signingPlan = await resolveGitSigningPlan(context);
|
|
3370
|
+
reportGitSigningPlan(signingPlan, {
|
|
3371
|
+
logger: options.logger,
|
|
3372
|
+
...progress === undefined ? {} : { writeWarning: (message) => {
|
|
3373
|
+
progress.warn(message.trimEnd());
|
|
3374
|
+
} }
|
|
3375
|
+
});
|
|
3376
|
+
writeGeneratedDevcontainerConfig(context, signingPlan);
|
|
3141
3377
|
if (hasConfigStep) {
|
|
3142
3378
|
progress?.completeStep("gh-auth-config");
|
|
3143
3379
|
}
|
|
@@ -3859,6 +4095,9 @@ function errorMessage$1(error) {
|
|
|
3859
4095
|
function argvText(command, args) {
|
|
3860
4096
|
return JSON.stringify([command, ...args]);
|
|
3861
4097
|
}
|
|
4098
|
+
function redactKnownSecretEnvironmentAssignments(value) {
|
|
4099
|
+
return value.replace(/ANTHROPIC_API_KEY=[^\s,"\]}]+/gu, "ANTHROPIC_API_KEY=[redacted]").replace(/SNYK_TOKEN=[^\s,"\]}]+/gu, "SNYK_TOKEN=[redacted]").replace(/OP_SERVICE_ACCOUNT_TOKEN=[^\s,"\]}]+/gu, "OP_SERVICE_ACCOUNT_TOKEN=[redacted]");
|
|
4100
|
+
}
|
|
3862
4101
|
var WorkspaceCommandLogger = class {
|
|
3863
4102
|
logPath;
|
|
3864
4103
|
workspaceFolder;
|
|
@@ -3909,7 +4148,7 @@ var WorkspaceCommandLogger = class {
|
|
|
3909
4148
|
return `[${this.#now().toISOString()}]`;
|
|
3910
4149
|
}
|
|
3911
4150
|
#redact(value) {
|
|
3912
|
-
return this.#redactions.reduce((current, redaction) => current.replaceAll(redaction, "[redacted]"), value);
|
|
4151
|
+
return this.#redactions.reduce((current, redaction) => current.replaceAll(redaction, "[redacted]"), redactKnownSecretEnvironmentAssignments(value));
|
|
3913
4152
|
}
|
|
3914
4153
|
#stream(stream, chunk) {
|
|
3915
4154
|
const text = this.#redact(Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk);
|
|
@@ -3992,6 +4231,15 @@ function defaultDataRoot(env = process.env) {
|
|
|
3992
4231
|
}
|
|
3993
4232
|
return join(homedir(), ".local", "share", PACKAGE_NAME);
|
|
3994
4233
|
}
|
|
4234
|
+
function defaultRuntimeRoot(env = process.env) {
|
|
4235
|
+
if (env.BOXDOWN_RUNTIME_HOME) {
|
|
4236
|
+
return env.BOXDOWN_RUNTIME_HOME;
|
|
4237
|
+
}
|
|
4238
|
+
if (env.XDG_RUNTIME_DIR) {
|
|
4239
|
+
return join(env.XDG_RUNTIME_DIR, PACKAGE_NAME);
|
|
4240
|
+
}
|
|
4241
|
+
return join(tmpdir(), `${PACKAGE_NAME}-${process.getuid?.() ?? "user"}`);
|
|
4242
|
+
}
|
|
3995
4243
|
function defaultHostAgentsDir(env = process.env) {
|
|
3996
4244
|
return join(env.HOME ?? homedir(), ".agents");
|
|
3997
4245
|
}
|
|
@@ -4007,10 +4255,12 @@ function createWorkspaceContextFromIdentity(identity, options = {}) {
|
|
|
4007
4255
|
const assetsDevcontainerDir = options.assetsDevcontainerDir ?? env.BOXDOWN_DEVCONTAINER_ASSETS_DIR ?? join(packageRoot, "assets", "devcontainer");
|
|
4008
4256
|
const cacheRoot = defaultCacheRoot(env);
|
|
4009
4257
|
const dataRoot = defaultDataRoot(env);
|
|
4258
|
+
const runtimeRoot = defaultRuntimeRoot(env);
|
|
4010
4259
|
const hostAgentsDir = defaultHostAgentsDir(env);
|
|
4011
4260
|
const hostCodexAuthPath = defaultHostCodexAuthPath(env);
|
|
4012
4261
|
const workspaceCacheDir = join(cacheRoot, "workspaces", identity.workspaceId);
|
|
4013
4262
|
const workspaceDataDir = join(dataRoot, "workspaces", identity.workspaceId);
|
|
4263
|
+
const workspaceRuntimeDir = join(runtimeRoot, "workspaces", identity.workspaceId);
|
|
4014
4264
|
const hostGitconfigSnapshotDir = join(workspaceDataDir, "gitconfig");
|
|
4015
4265
|
return {
|
|
4016
4266
|
workspaceFolder: identity.workspaceFolder,
|
|
@@ -4020,8 +4270,11 @@ function createWorkspaceContextFromIdentity(identity, options = {}) {
|
|
|
4020
4270
|
assetsDevcontainerDir,
|
|
4021
4271
|
cacheRoot,
|
|
4022
4272
|
dataRoot,
|
|
4273
|
+
runtimeRoot,
|
|
4023
4274
|
workspaceCacheDir,
|
|
4024
4275
|
workspaceDataDir,
|
|
4276
|
+
workspaceRuntimeDir,
|
|
4277
|
+
workspaceSecretEnvDir: join(workspaceRuntimeDir, "secrets"),
|
|
4025
4278
|
generatedConfigPath: join(workspaceCacheDir, "devcontainer.json"),
|
|
4026
4279
|
hostAgentsDir,
|
|
4027
4280
|
hostCodexAuthPath,
|
|
@@ -4110,6 +4363,9 @@ function removeWorkspaceStateDir(context, label, path, root) {
|
|
|
4110
4363
|
});
|
|
4111
4364
|
process.stdout.write(`Removed ${label}: ${path}\n`);
|
|
4112
4365
|
}
|
|
4366
|
+
function removeWorkspaceRuntimeState(context) {
|
|
4367
|
+
removeWorkspaceStateDir(context, "workspace runtime directory", context.workspaceRuntimeDir, context.runtimeRoot);
|
|
4368
|
+
}
|
|
4113
4369
|
async function purgeAliasIntegrations(context, alias) {
|
|
4114
4370
|
let failed = false;
|
|
4115
4371
|
failed = await runPurgeStep(`SSH alias ${alias}`, () => {
|
|
@@ -4186,6 +4442,9 @@ async function purgeWorkspace(context, options = {}) {
|
|
|
4186
4442
|
await removeDockerImage(removedImageId, { logger: options.logger });
|
|
4187
4443
|
}) || failed;
|
|
4188
4444
|
}
|
|
4445
|
+
failed = await runPurgeStep("workspace runtime directory", () => {
|
|
4446
|
+
removeWorkspaceRuntimeState(context);
|
|
4447
|
+
}) || failed;
|
|
4189
4448
|
failed = await runPurgeStep("workspace cache directory", () => {
|
|
4190
4449
|
removeWorkspaceStateDir(context, "workspace cache", context.workspaceCacheDir, context.cacheRoot);
|
|
4191
4450
|
}) || failed;
|
|
@@ -4891,6 +5150,7 @@ async function runDownCommand(workspaces, options) {
|
|
|
4891
5150
|
const context = createWorkspaceContext({ workspace });
|
|
4892
5151
|
await runLoggedLifecycle(context, "down", ["down", ...workspace === undefined ? [] : ["--workspace", workspace]], async (logger) => {
|
|
4893
5152
|
await removeWorkspaceContainer(context, { logger });
|
|
5153
|
+
removeWorkspaceRuntimeState(context);
|
|
4894
5154
|
});
|
|
4895
5155
|
} catch (error) {
|
|
4896
5156
|
failed = true;
|
|
@@ -5455,4 +5715,4 @@ async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
|
5455
5715
|
|
|
5456
5716
|
//#endregion
|
|
5457
5717
|
export { parseTunnelPortList as a, parseTunnelPort as i, commandWritesWorkspaceMetadata as n, runCli as o, parseCliArgs as r, setupWorkspace as s, USAGE as t };
|
|
5458
|
-
//# sourceMappingURL=main-
|
|
5718
|
+
//# sourceMappingURL=main-J4_2Up3o.mjs.map
|