fix(agentrun): authenticate managed repository imports

This commit is contained in:
Codex
2026-07-12 01:53:07 +02:00
parent 3820fcd785
commit 35021e1050
9 changed files with 617 additions and 66 deletions
+21
View File
@@ -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 ..`);