Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
227 lines
7.8 KiB
JavaScript
227 lines
7.8 KiB
JavaScript
"use strict";
|
|
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const { createHash } = require("node:crypto");
|
|
const { execFile } = require("node:child_process");
|
|
const { promisify } = require("node:util");
|
|
const { utils } = require("ssh2");
|
|
const { BaseAgent, createAgent } = require("ssh2/lib/agent.js");
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
function publicKeyBlob(key) {
|
|
try {
|
|
const parsed = typeof key?.getPublicSSH === "function" ? key : utils.parseKey(key);
|
|
if (parsed instanceof Error || typeof parsed?.getPublicSSH !== "function") return null;
|
|
return parsed.getPublicSSH().toString("base64");
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function resolveIdentityPath(rawPath, context = {}) {
|
|
if (typeof rawPath !== "string") return "";
|
|
const env = context.env ?? process.env;
|
|
const localHostname = context.localHostname || os.hostname();
|
|
const hostname = context.hostname || "";
|
|
const port = String(context.port || 22);
|
|
const username = context.username || "";
|
|
const proxyJump = context.proxyJump || "";
|
|
const tokenValues = {
|
|
"%": "%",
|
|
d: os.homedir(),
|
|
h: hostname,
|
|
i: String(context.uid ?? (typeof process.getuid === "function" ? process.getuid() : "")),
|
|
j: proxyJump,
|
|
k: context.hostKeyAlias || hostname,
|
|
L: context.shortLocalHostname || localHostname.split(".")[0],
|
|
l: localHostname,
|
|
n: context.originalHostname || hostname,
|
|
p: port,
|
|
r: username,
|
|
u: context.localUsername || os.userInfo().username,
|
|
};
|
|
tokenValues.C = createHash("sha1")
|
|
.update(`${localHostname}${hostname}${port}${username}${proxyJump}`)
|
|
.digest("hex");
|
|
let resolved = rawPath.trim()
|
|
.replace(/%([%CdhijkLlnpru])/g, (match, token) => tokenValues[token] ?? match)
|
|
.replace(/^~(?=$|[\\/])/, os.homedir())
|
|
.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => env[name] ?? "")
|
|
.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_match, name) => env[name] ?? "");
|
|
if (!path.isAbsolute(resolved) && resolved) {
|
|
resolved = path.resolve(resolved);
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
async function loadPreferredPublicKeyBlobs(identityFilePaths, publicKeys, options, deps) {
|
|
const preferred = new Set();
|
|
const resolvedIdentityPaths = [];
|
|
const unavailablePublicKeyPaths = [];
|
|
let providedPreferredCount = 0;
|
|
for (const publicKey of publicKeys ?? []) {
|
|
const blob = publicKeyBlob(publicKey);
|
|
if (blob) {
|
|
preferred.add(blob);
|
|
providedPreferredCount += 1;
|
|
}
|
|
}
|
|
for (const rawPath of identityFilePaths ?? []) {
|
|
const identityPath = resolveIdentityPath(rawPath, options);
|
|
if (!identityPath) continue;
|
|
resolvedIdentityPaths.push(identityPath);
|
|
const publicKeyPath = identityPath.endsWith(".pub") ? identityPath : `${identityPath}.pub`;
|
|
try {
|
|
const contents = await deps.readFile(publicKeyPath, "utf8");
|
|
const blob = publicKeyBlob(contents);
|
|
if (blob) preferred.add(blob);
|
|
else unavailablePublicKeyPaths.push(publicKeyPath);
|
|
} catch (error) {
|
|
unavailablePublicKeyPaths.push(publicKeyPath);
|
|
deps.log?.("Configured SSH public key is unavailable", {
|
|
publicKeyPath,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
return { preferred, providedPreferredCount, resolvedIdentityPaths, unavailablePublicKeyPaths };
|
|
}
|
|
|
|
function getIdentities(agent) {
|
|
return new Promise((resolve, reject) => {
|
|
agent.getIdentities((error, identities) => {
|
|
if (error) reject(error);
|
|
else resolve(Array.isArray(identities) ? identities : []);
|
|
});
|
|
});
|
|
}
|
|
|
|
class IdentityAwareAgent extends BaseAgent {
|
|
constructor(delegate, preferred, identitiesOnly) {
|
|
super();
|
|
this.delegate = delegate;
|
|
this.preferred = preferred;
|
|
this.identitiesOnly = identitiesOnly;
|
|
}
|
|
|
|
getIdentities(callback) {
|
|
this.delegate.getIdentities((error, identities) => {
|
|
if (error) return callback(error);
|
|
const keys = Array.isArray(identities) ? identities : [];
|
|
const matching = [];
|
|
const remaining = [];
|
|
for (const key of keys) {
|
|
if (this.preferred.has(publicKeyBlob(key))) matching.push(key);
|
|
else remaining.push(key);
|
|
}
|
|
callback(null, this.identitiesOnly ? matching : [...matching, ...remaining]);
|
|
});
|
|
}
|
|
|
|
sign(publicKey, data, options, callback) {
|
|
this.delegate.sign(publicKey, data, options, callback);
|
|
}
|
|
|
|
getStream(callback) {
|
|
if (typeof this.delegate.getStream !== "function") {
|
|
callback(new Error("SSH agent does not support forwarding streams"));
|
|
return;
|
|
}
|
|
this.delegate.getStream(callback);
|
|
}
|
|
}
|
|
|
|
function shouldLoadFromMacKeychain(options, platform) {
|
|
return platform === "darwin"
|
|
&& options.useKeychain === true
|
|
&& typeof options.addKeysToAgent === "string"
|
|
&& options.addKeysToAgent.toLowerCase() === "yes"
|
|
&& Array.isArray(options.identityFilePaths)
|
|
&& options.identityFilePaths.length > 0;
|
|
}
|
|
|
|
async function defaultRunSshAdd(args, { socketPath, env }) {
|
|
await execFileAsync("/usr/bin/ssh-add", args, {
|
|
timeout: 5000,
|
|
windowsHide: true,
|
|
env: {
|
|
...env,
|
|
SSH_AUTH_SOCK: socketPath,
|
|
SSH_ASKPASS_REQUIRE: "never",
|
|
},
|
|
});
|
|
}
|
|
|
|
async function prepareSystemSshAgent(options, injected = {}) {
|
|
if (!options?.socketPath) return null;
|
|
const deps = {
|
|
createAgent: injected.createAgent ?? createAgent,
|
|
readFile: injected.readFile ?? fs.promises.readFile,
|
|
runSshAdd: injected.runSshAdd,
|
|
platform: injected.platform ?? process.platform,
|
|
env: injected.env ?? process.env,
|
|
log: injected.log,
|
|
};
|
|
const agent = deps.createAgent(options.socketPath);
|
|
const { preferred, providedPreferredCount, resolvedIdentityPaths, unavailablePublicKeyPaths } = await loadPreferredPublicKeyBlobs(
|
|
options.identityFilePaths,
|
|
options.agentPublicKeys,
|
|
{ hostname: options.hostname, port: options.port, username: options.username, env: deps.env },
|
|
deps,
|
|
);
|
|
|
|
if (shouldLoadFromMacKeychain(options, deps.platform)) {
|
|
let loadedBlobs = new Set();
|
|
try {
|
|
loadedBlobs = new Set((await getIdentities(agent)).map(publicKeyBlob).filter(Boolean));
|
|
} catch (error) {
|
|
deps.log?.("Could not inspect SSH agent identities before Keychain load", {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
// Without a readable .pub selector we cannot tell whether the configured
|
|
// identity is already loaded, so still ask Apple's ssh-add to load it.
|
|
// In non-strict mode the delegate can then safely advertise the full list.
|
|
const hasEveryPreferredIdentity = unavailablePublicKeyPaths.length === 0
|
|
&& preferred.size > 0
|
|
&& [...preferred].every((blob) => loadedBlobs.has(blob));
|
|
if (!hasEveryPreferredIdentity) {
|
|
try {
|
|
const args = ["--apple-load-keychain", ...resolvedIdentityPaths];
|
|
if (deps.runSshAdd) await deps.runSshAdd(args);
|
|
else await defaultRunSshAdd(args, { socketPath: options.socketPath, env: deps.env });
|
|
} catch (error) {
|
|
deps.log?.("macOS Keychain could not load the configured SSH identity", {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (
|
|
options.identitiesOnly === true
|
|
&& (preferred.size === 0 || (unavailablePublicKeyPaths.length > 0 && providedPreferredCount === 0))
|
|
) {
|
|
const error = new Error(
|
|
unavailablePublicKeyPaths.length > 0
|
|
? `IdentitiesOnly requires a readable public key selector. Missing or invalid: ${unavailablePublicKeyPaths.join(", ")}`
|
|
: "IdentitiesOnly requires at least one IdentityFile with a readable public .pub key.",
|
|
);
|
|
error.code = "ERR_SSH_AGENT_IDENTITY_SELECTOR_UNAVAILABLE";
|
|
throw error;
|
|
}
|
|
|
|
return new IdentityAwareAgent(agent, preferred, options.identitiesOnly === true);
|
|
}
|
|
|
|
module.exports = {
|
|
IdentityAwareAgent,
|
|
prepareSystemSshAgent,
|
|
publicKeyBlob,
|
|
resolveIdentityPath,
|
|
shouldLoadFromMacKeychain,
|
|
};
|