fix(agentrun): authenticate managed repository imports
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const tokenPath = new URL("./token", import.meta.url);
|
||||
const prompt = process.argv[2] ?? "";
|
||||
|
||||
if (/username/iu.test(prompt)) {
|
||||
process.stdout.write("x-access-token\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!/password/iu.test(prompt)) process.exit(1);
|
||||
|
||||
let token;
|
||||
try {
|
||||
token = readFileSync(tokenPath, "utf8").trim();
|
||||
} catch {
|
||||
process.exit(1);
|
||||
}
|
||||
if (token.length === 0 || /[\u0000\r\n]/u.test(token)) process.exit(1);
|
||||
process.stdout.write(`${token}\n`);
|
||||
@@ -87,6 +87,20 @@ function readConfig(path) {
|
||||
if (raw.schemaVersion !== 1 || raw.kind !== "AgentRunManagedRepositoryReconciler") throw new Error("unsupported managed repository config envelope");
|
||||
const cacheMountPath = requireAbsolutePath(raw.cacheMountPath, "cacheMountPath");
|
||||
const stateDirectory = requireRelativePath(raw.stateDirectory, "stateDirectory");
|
||||
requireObject(raw.remoteAuth, "remoteAuth");
|
||||
requireObject(raw.remoteAuth.proxy, "remoteAuth.proxy");
|
||||
const remoteAuth = {
|
||||
configRef: requireString(raw.remoteAuth.configRef, "remoteAuth.configRef"),
|
||||
capabilitySha256: requireHex(raw.remoteAuth.capabilitySha256, "remoteAuth.capabilitySha256"),
|
||||
authMode: requireLiteral(raw.remoteAuth.authMode, "remoteAuth.authMode", "github-https-token"),
|
||||
host: requireHost(raw.remoteAuth.host, "remoteAuth.host"),
|
||||
tokenFile: requireAbsolutePath(raw.remoteAuth.tokenFile, "remoteAuth.tokenFile"),
|
||||
askPassFile: requireAbsolutePath(raw.remoteAuth.askPassFile, "remoteAuth.askPassFile"),
|
||||
proxy: {
|
||||
host: requireHost(raw.remoteAuth.proxy.host, "remoteAuth.proxy.host"),
|
||||
port: requirePort(raw.remoteAuth.proxy.port, "remoteAuth.proxy.port"),
|
||||
},
|
||||
};
|
||||
const repositories = requireArray(raw.repositories, "repositories").map((item, index) => {
|
||||
requireObject(item, `repositories[${index}]`);
|
||||
const key = requireSimpleId(item.key, `repositories[${index}].key`);
|
||||
@@ -96,6 +110,7 @@ function readConfig(path) {
|
||||
const desiredFingerprint = requireHex(item.desiredFingerprint, `repositories[${index}].desiredFingerprint`);
|
||||
const expectedFingerprint = sha256([key, repository, remote, sourceBranch].join("\0"));
|
||||
if (desiredFingerprint !== expectedFingerprint) throw new Error(`repositories[${index}].desiredFingerprint does not match repository desired state`);
|
||||
requireCanonicalRemote(remote, repository, remoteAuth.host, `repositories[${index}].remote`);
|
||||
return { key, repository, remote, sourceBranch, desiredFingerprint };
|
||||
});
|
||||
if (repositories.length === 0) throw new Error("repositories must not be empty");
|
||||
@@ -133,18 +148,13 @@ function readConfig(path) {
|
||||
managedRefs: "source-branch-only",
|
||||
},
|
||||
repositories,
|
||||
ssh: raw.ssh && typeof raw.ssh === "object" ? {
|
||||
identityFile: requireAbsolutePath(raw.ssh.identityFile, "ssh.identityFile"),
|
||||
knownHostsFile: requireAbsolutePath(raw.ssh.knownHostsFile, "ssh.knownHostsFile"),
|
||||
proxyConnectScript: requireAbsolutePath(raw.ssh.proxyConnectScript, "ssh.proxyConnectScript"),
|
||||
proxyHost: requireString(raw.ssh.proxyHost, "ssh.proxyHost"),
|
||||
proxyPort: requirePositiveInteger(raw.ssh.proxyPort, "ssh.proxyPort"),
|
||||
} : null,
|
||||
remoteAuth,
|
||||
};
|
||||
}
|
||||
|
||||
async function reconcileOnce(cfg, path) {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const credential = credentialStatus(cfg);
|
||||
const previous = readState(path);
|
||||
const previousByFingerprint = new Map(
|
||||
Array.isArray(previous?.repositories)
|
||||
@@ -165,8 +175,9 @@ async function reconcileOnce(cfg, path) {
|
||||
lane: cfg.lane,
|
||||
observedAt: now,
|
||||
heartbeatAt: now,
|
||||
ready: repositories.every((item) => item.fresh === true && item.refPresent === true),
|
||||
ready: credential.ready === true && repositories.every((item) => item.fresh === true && item.refPresent === true),
|
||||
lastCycleOk: repositories.every((item) => item.lastAttemptOk === true),
|
||||
credential,
|
||||
lifecycle: cfg.lifecycle,
|
||||
repositories,
|
||||
valuesPrinted: false,
|
||||
@@ -219,6 +230,7 @@ async function reconcileWithRetry(cfg, repository, prior) {
|
||||
}
|
||||
|
||||
async function materializeRepository(cfg, repository) {
|
||||
assertCredentialReady(cfg);
|
||||
const finalPath = repositoryPath(cfg, repository);
|
||||
mkdirSync(dirname(finalPath), { recursive: true });
|
||||
const existing = existsSync(finalPath);
|
||||
@@ -277,25 +289,34 @@ async function isBareRepository(path, timeoutMs) {
|
||||
}
|
||||
|
||||
async function git(cfg, gitArgs) {
|
||||
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
|
||||
if (cfg.ssh) {
|
||||
mkdirSync(dirname(cfg.ssh.knownHostsFile), { recursive: true });
|
||||
const proxyCommand = `node ${shellWord(cfg.ssh.proxyConnectScript)} ${shellWord(cfg.ssh.proxyHost)} ${cfg.ssh.proxyPort} %h %p`;
|
||||
env.GIT_SSH_COMMAND = [
|
||||
"ssh",
|
||||
"-i", shellWord(cfg.ssh.identityFile),
|
||||
"-o", "IdentitiesOnly=yes",
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", `UserKnownHostsFile=${shellWord(cfg.ssh.knownHostsFile)}`,
|
||||
"-o", "ConnectTimeout=15",
|
||||
"-o", `ProxyCommand=${shellWord(proxyCommand)}`,
|
||||
].join(" ");
|
||||
}
|
||||
return run("git", gitArgs, { timeoutMs: cfg.fetchTimeoutMs, env });
|
||||
const proxyUrl = `http://${cfg.remoteAuth.proxy.host}:${cfg.remoteAuth.proxy.port}`;
|
||||
const env = {
|
||||
PATH: process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
LANG: process.env.LANG ?? "C.UTF-8",
|
||||
LC_ALL: process.env.LC_ALL ?? "C.UTF-8",
|
||||
GIT_TERMINAL_PROMPT: "0",
|
||||
GIT_ASKPASS: cfg.remoteAuth.askPassFile,
|
||||
GIT_ASKPASS_REQUIRE: "force",
|
||||
GIT_CONFIG_NOSYSTEM: "1",
|
||||
GIT_CONFIG_GLOBAL: "/dev/null",
|
||||
GIT_SSL_NO_VERIFY: "0",
|
||||
HTTPS_PROXY: proxyUrl,
|
||||
HTTP_PROXY: proxyUrl,
|
||||
https_proxy: proxyUrl,
|
||||
http_proxy: proxyUrl,
|
||||
NO_PROXY: "",
|
||||
no_proxy: "",
|
||||
};
|
||||
return run("git", [
|
||||
"-c", "credential.helper=",
|
||||
"-c", "http.extraHeader=",
|
||||
"-c", "http.sslVerify=true",
|
||||
...gitArgs,
|
||||
], { timeoutMs: cfg.fetchTimeoutMs, env });
|
||||
}
|
||||
|
||||
async function currentStatus(cfg, path) {
|
||||
const credential = credentialStatus(cfg);
|
||||
const state = readState(path);
|
||||
if (!state || state.desiredHash !== cfg.desiredHash || !Array.isArray(state.repositories)) {
|
||||
return {
|
||||
@@ -305,6 +326,7 @@ async function currentStatus(cfg, path) {
|
||||
observedDesiredHash: state?.desiredHash ?? null,
|
||||
reason: state ? "desired-state-mismatch" : "status-missing",
|
||||
repositoryCount: cfg.repositories.length,
|
||||
credential,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
@@ -331,7 +353,7 @@ async function currentStatus(cfg, path) {
|
||||
}
|
||||
const heartbeatAgeMs = isoAgeMs(state.heartbeatAt);
|
||||
const heartbeatFresh = heartbeatAgeMs !== null && heartbeatAgeMs <= cfg.freshness.maxAgeMs;
|
||||
const ready = heartbeatFresh && repositories.every((item) => item.refPresent === true && item.fresh === true && item.remoteFingerprintAligned === true);
|
||||
const ready = credential.ready === true && heartbeatFresh && repositories.every((item) => item.refPresent === true && item.fresh === true && item.remoteFingerprintAligned === true);
|
||||
return {
|
||||
ok: ready,
|
||||
ready,
|
||||
@@ -343,6 +365,7 @@ async function currentStatus(cfg, path) {
|
||||
heartbeatAgeMs,
|
||||
heartbeatFresh,
|
||||
lifecycle: cfg.lifecycle,
|
||||
credential,
|
||||
repositories,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
@@ -357,6 +380,7 @@ function compactStatus(cfg, state) {
|
||||
lane: cfg.lane,
|
||||
heartbeatAt: state.heartbeatAt,
|
||||
lifecycle: cfg.lifecycle,
|
||||
credential: state.credential,
|
||||
repositories: state.repositories,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
@@ -518,10 +542,6 @@ function isoAgeMs(value) {
|
||||
return Number.isFinite(time) ? Math.max(0, Date.now() - time) : null;
|
||||
}
|
||||
|
||||
function shellWord(value) {
|
||||
return `'${String(value).replaceAll("'", `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function sha256(value) {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
@@ -550,6 +570,67 @@ function requirePositiveInteger(value, path) {
|
||||
return value;
|
||||
}
|
||||
|
||||
function requirePort(value, path) {
|
||||
const port = requirePositiveInteger(value, path);
|
||||
if (port > 65535) throw new Error(`${path} must be at most 65535`);
|
||||
return port;
|
||||
}
|
||||
|
||||
function requireLiteral(value, path, expected) {
|
||||
if (value !== expected) throw new Error(`${path} must be ${expected}`);
|
||||
return expected;
|
||||
}
|
||||
|
||||
function requireHost(value, path) {
|
||||
const host = requireString(value, path);
|
||||
if (host !== host.toLowerCase() || !/^(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?|\d{1,3}(?:\.\d{1,3}){3})$/u.test(host) || host.includes("..")) {
|
||||
throw new Error(`${path} must be a lowercase hostname or IPv4 address without a port`);
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
function requireCanonicalRemote(value, repository, host, path) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw new Error(`${path} must be a canonical HTTPS URL`);
|
||||
}
|
||||
const expected = `https://${host}/${repository}.git`;
|
||||
if (
|
||||
value !== expected
|
||||
|| parsed.protocol !== "https:"
|
||||
|| parsed.hostname !== host
|
||||
|| parsed.port !== ""
|
||||
|| parsed.username !== ""
|
||||
|| parsed.password !== ""
|
||||
|| parsed.search !== ""
|
||||
|| parsed.hash !== ""
|
||||
) throw new Error(`${path} must exactly match ${expected} without userinfo, port, query, or fragment`);
|
||||
}
|
||||
|
||||
function credentialStatus(cfg) {
|
||||
try {
|
||||
assertCredentialReady(cfg);
|
||||
return { ready: true, errorCode: null, errorFingerprint: null, valuesPrinted: false };
|
||||
} catch (error) {
|
||||
const failure = safeError(error);
|
||||
return { ready: false, errorCode: failure.code, errorFingerprint: failure.fingerprint, valuesPrinted: false };
|
||||
}
|
||||
}
|
||||
|
||||
function assertCredentialReady(cfg) {
|
||||
if (!existsSync(cfg.remoteAuth.askPassFile)) throw codedError("credential-helper-missing", "rendered credential helper is missing");
|
||||
let token;
|
||||
try {
|
||||
token = readFileSync(cfg.remoteAuth.tokenFile, "utf8").trim();
|
||||
} catch {
|
||||
throw codedError("credential-file-missing", "projected credential file is missing or unreadable");
|
||||
}
|
||||
if (token.length === 0) throw codedError("credential-empty", "projected credential file is empty");
|
||||
if (/[\u0000\r\n]/u.test(token)) throw codedError("credential-invalid", "projected credential file contains unsupported control characters");
|
||||
}
|
||||
|
||||
function requireAbsolutePath(value, path) {
|
||||
const text = requireString(value, path);
|
||||
if (!text.startsWith("/") || text.includes("..")) throw new Error(`${path} must be an absolute path without ..`);
|
||||
|
||||
Reference in New Issue
Block a user