fix: bound runner git transport (#169)
Co-authored-by: AgentRun Codex <agentrun-codex@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
|||||||
|
import type { JsonRecord } from "./types.js";
|
||||||
|
|
||||||
|
export const defaultGitOperationTimeoutMs = 60_000;
|
||||||
|
export const defaultGitConnectTimeoutSeconds = 10;
|
||||||
|
export const defaultGitLowSpeedLimitBytes = 1_024;
|
||||||
|
export const defaultGitLowSpeedTimeSeconds = 15;
|
||||||
|
export const defaultGitHttpVersion = "HTTP/1.1";
|
||||||
|
|
||||||
|
export const defaultGitDirectHosts = Object.freeze([
|
||||||
|
"github.com",
|
||||||
|
"api.github.com",
|
||||||
|
"codeload.github.com",
|
||||||
|
"objects.githubusercontent.com",
|
||||||
|
"raw.githubusercontent.com",
|
||||||
|
"registry.npmjs.org",
|
||||||
|
"registry.npmmirror.com",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function runnerGitTransportEnvVars(): JsonRecord[] {
|
||||||
|
return [
|
||||||
|
{ name: "GIT_TERMINAL_PROMPT", value: "0" },
|
||||||
|
{ name: "GIT_HTTP_VERSION", value: defaultGitHttpVersion },
|
||||||
|
{ name: "GIT_HTTP_LOW_SPEED_LIMIT", value: String(defaultGitLowSpeedLimitBytes) },
|
||||||
|
{ name: "GIT_HTTP_LOW_SPEED_TIME", value: String(defaultGitLowSpeedTimeSeconds) },
|
||||||
|
{ name: "AGENTRUN_GIT_DEFAULT_TIMEOUT_MS", value: String(defaultGitOperationTimeoutMs) },
|
||||||
|
{ name: "AGENTRUN_GIT_CONNECT_TIMEOUT_SECONDS", value: String(defaultGitConnectTimeoutSeconds) },
|
||||||
|
{ name: "AGENTRUN_GIT_HTTP_VERSION", value: defaultGitHttpVersion },
|
||||||
|
{ name: "AGENTRUN_GIT_DIRECT_HOSTS", value: defaultGitDirectHosts.join(",") },
|
||||||
|
{ name: "AGENTRUN_GIT_CREDENTIAL_HELPER", value: "gh-auth-setup-git" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gitTransportSummary(): JsonRecord {
|
||||||
|
return {
|
||||||
|
terminalPrompt: false,
|
||||||
|
defaultTimeoutMs: defaultGitOperationTimeoutMs,
|
||||||
|
connectTimeoutSeconds: defaultGitConnectTimeoutSeconds,
|
||||||
|
httpVersion: defaultGitHttpVersion,
|
||||||
|
lowSpeedLimitBytes: defaultGitLowSpeedLimitBytes,
|
||||||
|
lowSpeedTimeSeconds: defaultGitLowSpeedTimeSeconds,
|
||||||
|
credentialHelper: "gh-auth-setup-git",
|
||||||
|
directHosts: [...defaultGitDirectHosts],
|
||||||
|
valuesPrinted: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import type { JsonRecord } from "./types.js";
|
|||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
const toolTimeoutMs = 5_000;
|
const toolTimeoutMs = 5_000;
|
||||||
|
|
||||||
export const workReadyVersion = "v0.1-runner-work-ready-20260610";
|
export const workReadyVersion = "v0.1-runner-work-ready-20260611";
|
||||||
|
|
||||||
export const imageWorkReadyTools = Object.freeze([
|
export const imageWorkReadyTools = Object.freeze([
|
||||||
{ name: "bun", command: "bun", args: ["--version"] },
|
{ name: "bun", command: "bun", args: ["--version"] },
|
||||||
@@ -27,6 +27,7 @@ export const bundledWorkReadyTools = Object.freeze([
|
|||||||
{ name: "tran", path: "/usr/local/bin/tran" },
|
{ name: "tran", path: "/usr/local/bin/tran" },
|
||||||
{ name: "trans", path: "/usr/local/bin/trans" },
|
{ name: "trans", path: "/usr/local/bin/trans" },
|
||||||
{ name: "apply_patch", path: "/usr/local/bin/apply_patch" },
|
{ name: "apply_patch", path: "/usr/local/bin/apply_patch" },
|
||||||
|
{ name: "agentrun-git", path: "/usr/local/bin/agentrun-git" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function staticWorkReadyCapabilitySummary(): JsonRecord {
|
export function staticWorkReadyCapabilitySummary(): JsonRecord {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { RunnerSessionPvcOptions, RunnerTransientEnv } from "../runner/k8s-
|
|||||||
import { staticWorkReadyCapabilitySummary } from "../common/work-ready.js";
|
import { staticWorkReadyCapabilitySummary } from "../common/work-ready.js";
|
||||||
import { resolveRunnerEnvImage } from "../common/env-image-ref.js";
|
import { resolveRunnerEnvImage } from "../common/env-image-ref.js";
|
||||||
import { ensureSessionPvc } from "./session-pvc.js";
|
import { ensureSessionPvc } from "./session-pvc.js";
|
||||||
|
import { gitTransportSummary } from "../common/git-transport.js";
|
||||||
|
|
||||||
const reusableCredentialEnvNames = new Set([
|
const reusableCredentialEnvNames = new Set([
|
||||||
"AUTH_PASSWORD",
|
"AUTH_PASSWORD",
|
||||||
@@ -204,6 +205,7 @@ export async function createKubernetesRunnerJob(options: { store: AgentRunStore;
|
|||||||
envImage,
|
envImage,
|
||||||
secretRefs: render.secretRefs.map((item) => ({ profile: item.profile, name: item.secretRef.name, namespace: item.secretRef.namespace ?? render.namespace, keys: item.secretRef.keys ?? [], mountPath: item.runtimeMountPath, projectionPath: item.projectionMountPath, writableCopy: true, valuesPrinted: false })),
|
secretRefs: render.secretRefs.map((item) => ({ profile: item.profile, name: item.secretRef.name, namespace: item.secretRef.namespace ?? render.namespace, keys: item.secretRef.keys ?? [], mountPath: item.runtimeMountPath, projectionPath: item.projectionMountPath, writableCopy: true, valuesPrinted: false })),
|
||||||
toolCredentials: summarizeToolCredentials(render.toolCredentials, render.namespace),
|
toolCredentials: summarizeToolCredentials(render.toolCredentials, render.namespace),
|
||||||
|
gitTransport: gitTransportSummary(),
|
||||||
transientEnv: summarizeTransientEnv(transientEnv),
|
transientEnv: summarizeTransientEnv(transientEnv),
|
||||||
transientEnvSecret: transientEnvSecretResponse,
|
transientEnvSecret: transientEnvSecretResponse,
|
||||||
sessionPvc: sessionPvcSummary,
|
sessionPvc: sessionPvcSummary,
|
||||||
@@ -251,6 +253,7 @@ export async function createKubernetesRunnerJob(options: { store: AgentRunStore;
|
|||||||
transientEnv: summarizeTransientEnv(transientEnv),
|
transientEnv: summarizeTransientEnv(transientEnv),
|
||||||
transientEnvSecret: transientEnvSecretResponse,
|
transientEnvSecret: transientEnvSecretResponse,
|
||||||
envImage,
|
envImage,
|
||||||
|
gitTransport: gitTransportSummary(),
|
||||||
workReady: staticWorkReadyCapabilitySummary(),
|
workReady: staticWorkReadyCapabilitySummary(),
|
||||||
toolCredentials: summarizeToolCredentials(render.toolCredentials, render.namespace),
|
toolCredentials: summarizeToolCredentials(render.toolCredentials, render.namespace),
|
||||||
sessionPvc: sessionPvcSummary,
|
sessionPvc: sessionPvcSummary,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { stableHash } from "../common/validation.js";
|
|||||||
import type { BackendProfile, ExecutionPolicy, JsonRecord, JsonValue, RunRecord, SecretRef } from "../common/types.js";
|
import type { BackendProfile, ExecutionPolicy, JsonRecord, JsonValue, RunRecord, SecretRef } from "../common/types.js";
|
||||||
import { backendProfileSpec } from "../common/backend-profiles.js";
|
import { backendProfileSpec } from "../common/backend-profiles.js";
|
||||||
import { staticWorkReadyCapabilitySummary } from "../common/work-ready.js";
|
import { staticWorkReadyCapabilitySummary } from "../common/work-ready.js";
|
||||||
|
import { gitTransportSummary, runnerGitTransportEnvVars } from "../common/git-transport.js";
|
||||||
|
|
||||||
const defaultBootRepoUrl = "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/agentrun.git";
|
const defaultBootRepoUrl = "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/agentrun.git";
|
||||||
const defaultResourceBinPath = "/usr/local/bin";
|
const defaultResourceBinPath = "/usr/local/bin";
|
||||||
@@ -23,6 +24,11 @@ const defaultRunnerNoProxy = [
|
|||||||
"g14-provider-egress-proxy.unidesk",
|
"g14-provider-egress-proxy.unidesk",
|
||||||
"g14-provider-egress-proxy.unidesk.svc",
|
"g14-provider-egress-proxy.unidesk.svc",
|
||||||
"g14-provider-egress-proxy.unidesk.svc.cluster.local",
|
"g14-provider-egress-proxy.unidesk.svc.cluster.local",
|
||||||
|
"github.com",
|
||||||
|
"api.github.com",
|
||||||
|
"codeload.github.com",
|
||||||
|
"objects.githubusercontent.com",
|
||||||
|
"raw.githubusercontent.com",
|
||||||
"registry.npmjs.org",
|
"registry.npmjs.org",
|
||||||
"registry.npmmirror.com",
|
"registry.npmmirror.com",
|
||||||
"g14-tcp-egress-gateway",
|
"g14-tcp-egress-gateway",
|
||||||
@@ -126,6 +132,7 @@ export function renderRunnerJobDryRun(options: RunnerJobRenderOptions): JsonReco
|
|||||||
},
|
},
|
||||||
secretRefs: render.secretRefs.map((item) => ({ profile: item.profile, name: item.secretRef.name, namespace: item.secretRef.namespace ?? render.namespace, keys: item.secretRef.keys ?? [], mountPath: item.runtimeMountPath, projectionPath: item.projectionMountPath, writableCopy: true, valuesPrinted: false })),
|
secretRefs: render.secretRefs.map((item) => ({ profile: item.profile, name: item.secretRef.name, namespace: item.secretRef.namespace ?? render.namespace, keys: item.secretRef.keys ?? [], mountPath: item.runtimeMountPath, projectionPath: item.projectionMountPath, writableCopy: true, valuesPrinted: false })),
|
||||||
toolCredentials: summarizeToolCredentials(render.toolCredentials, render.namespace),
|
toolCredentials: summarizeToolCredentials(render.toolCredentials, render.namespace),
|
||||||
|
gitTransport: gitTransportSummary(),
|
||||||
transientEnv: summarizeTransientEnv(options.transientEnv ?? []),
|
transientEnv: summarizeTransientEnv(options.transientEnv ?? []),
|
||||||
workReady: staticWorkReadyCapabilitySummary(),
|
workReady: staticWorkReadyCapabilitySummary(),
|
||||||
retention: {
|
retention: {
|
||||||
@@ -257,6 +264,7 @@ function runnerEnv(options: RunnerJobRenderOptions, context: { namespace: string
|
|||||||
{ name: "AGENTRUN_CODEX_ROLLOUT_SUBDIR", value: context.sessionPvc.codexRolloutSubdir },
|
{ name: "AGENTRUN_CODEX_ROLLOUT_SUBDIR", value: context.sessionPvc.codexRolloutSubdir },
|
||||||
] : []),
|
] : []),
|
||||||
...runnerEgressProxyEnvVars(),
|
...runnerEgressProxyEnvVars(),
|
||||||
|
...runnerGitTransportEnvVars(),
|
||||||
...toolCredentialEnvVars(context.toolCredentials),
|
...toolCredentialEnvVars(context.toolCredentials),
|
||||||
...transientEnvVars(options.transientEnv ?? []),
|
...transientEnvVars(options.transientEnv ?? []),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
|||||||
import { AgentRunError } from "../common/errors.js";
|
import { AgentRunError } from "../common/errors.js";
|
||||||
import { redactText } from "../common/redaction.js";
|
import { redactText } from "../common/redaction.js";
|
||||||
import type { InitialPromptAssembly, JsonRecord, ResourceBundleRef } from "../common/types.js";
|
import type { InitialPromptAssembly, JsonRecord, ResourceBundleRef } from "../common/types.js";
|
||||||
|
import { defaultGitDirectHosts, defaultGitHttpVersion, defaultGitLowSpeedLimitBytes, defaultGitLowSpeedTimeSeconds, defaultGitOperationTimeoutMs, gitTransportSummary } from "../common/git-transport.js";
|
||||||
import { stableHash } from "../common/validation.js";
|
import { stableHash } from "../common/validation.js";
|
||||||
|
|
||||||
const maxPromptRefBytes = 16 * 1024;
|
const maxPromptRefBytes = 16 * 1024;
|
||||||
@@ -128,6 +129,7 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl
|
|||||||
treeId: defaultCheckout.treeId,
|
treeId: defaultCheckout.treeId,
|
||||||
checkoutPath: pathSummary(defaultCheckout.checkoutPath),
|
checkoutPath: pathSummary(defaultCheckout.checkoutPath),
|
||||||
workspacePath: pathSummary(workspacePath),
|
workspacePath: pathSummary(workspacePath),
|
||||||
|
gitTransport: gitTransportSummary(),
|
||||||
bundles: {
|
bundles: {
|
||||||
count: materializedBundles.length,
|
count: materializedBundles.length,
|
||||||
items: materializedBundles.map((item) => ({ ...item, valuesPrinted: false })),
|
items: materializedBundles.map((item) => ({ ...item, valuesPrinted: false })),
|
||||||
@@ -182,10 +184,10 @@ async function checkoutGitSource(checkoutRoot: string, source: GitBundleSource):
|
|||||||
await git(["remote", "remove", "origin"], checkoutPath, { allowFailure: true });
|
await git(["remote", "remove", "origin"], checkoutPath, { allowFailure: true });
|
||||||
await git(["remote", "add", "origin", fetch.fetchRepoUrl], checkoutPath);
|
await git(["remote", "add", "origin", fetch.fetchRepoUrl], checkoutPath);
|
||||||
if (source.ref) {
|
if (source.ref) {
|
||||||
await git(["fetch", "--depth", "1", "origin", source.ref], checkoutPath);
|
await git(["fetch", "--depth", "1", "origin", source.ref], checkoutPath, { operation: "fetch", repoUrl: source.repoUrl, fetchRepoUrl: fetch.fetchRepoUrl });
|
||||||
await git(["checkout", "--detach", "FETCH_HEAD"], checkoutPath);
|
await git(["checkout", "--detach", "FETCH_HEAD"], checkoutPath);
|
||||||
} else if (source.commitId) {
|
} else if (source.commitId) {
|
||||||
await git(["fetch", "--depth", "1", "origin", source.commitId], checkoutPath);
|
await git(["fetch", "--depth", "1", "origin", source.commitId], checkoutPath, { operation: "fetch", repoUrl: source.repoUrl, fetchRepoUrl: fetch.fetchRepoUrl });
|
||||||
await git(["checkout", "--detach", source.commitId], checkoutPath);
|
await git(["checkout", "--detach", source.commitId], checkoutPath);
|
||||||
} else {
|
} else {
|
||||||
throw new AgentRunError("schema-invalid", "gitbundle source must include repo ref or commit", { httpStatus: 400 });
|
throw new AgentRunError("schema-invalid", "gitbundle source must include repo ref or commit", { httpStatus: 400 });
|
||||||
@@ -514,26 +516,109 @@ function fileErrorSummary(error: unknown): JsonRecord {
|
|||||||
return { code: typeof record.code === "string" ? record.code : null, message: typeof record.message === "string" ? redactText(record.message).slice(0, 300) : null };
|
return { code: typeof record.code === "string" ? record.code : null, message: typeof record.message === "string" ? redactText(record.message).slice(0, 300) : null };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function git(args: string[], cwd: string, options: { allowFailure?: boolean } = {}): Promise<{ stdout: string; stderr: string }> {
|
async function git(args: string[], cwd: string, options: { allowFailure?: boolean; operation?: string; repoUrl?: string; fetchRepoUrl?: string } = {}): Promise<{ stdout: string; stderr: string }> {
|
||||||
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
const env = gitCommandEnv(process.env);
|
||||||
|
const child = spawn("git", gitArgs(args), { cwd, stdio: ["ignore", "pipe", "pipe"], env });
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
child.stdout.setEncoding("utf8");
|
child.stdout.setEncoding("utf8");
|
||||||
child.stderr.setEncoding("utf8");
|
child.stderr.setEncoding("utf8");
|
||||||
child.stdout.on("data", (chunk) => { stdout += String(chunk); });
|
child.stdout.on("data", (chunk) => { stdout += String(chunk); });
|
||||||
child.stderr.on("data", (chunk) => { stderr += String(chunk); });
|
child.stderr.on("data", (chunk) => { stderr += String(chunk); });
|
||||||
|
const timeoutMs = gitTimeoutMs(process.env);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
let timedOut = false;
|
||||||
|
let closed = false;
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!closed) child.kill("SIGKILL");
|
||||||
|
}, 2_000).unref();
|
||||||
|
}, timeoutMs);
|
||||||
const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
|
const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
|
||||||
child.on("error", reject);
|
child.on("error", reject);
|
||||||
child.on("close", (code, signal) => resolve({ code, signal }));
|
child.on("close", (code, signal) => {
|
||||||
|
closed = true;
|
||||||
|
resolve({ code, signal });
|
||||||
|
});
|
||||||
}).catch((error: unknown) => {
|
}).catch((error: unknown) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
throw new AgentRunError("infra-failed", `failed to start git: ${error instanceof Error ? error.message : String(error)}`, { httpStatus: 503 });
|
throw new AgentRunError("infra-failed", `failed to start git: ${error instanceof Error ? error.message : String(error)}`, { httpStatus: 503 });
|
||||||
});
|
});
|
||||||
|
clearTimeout(timeout);
|
||||||
if (result.code !== 0 && !options.allowFailure) {
|
if (result.code !== 0 && !options.allowFailure) {
|
||||||
throw new AgentRunError("infra-failed", `git ${args[0] ?? "command"} failed with code ${result.code}`, { httpStatus: 502, details: { stderr: redactText(stderr.slice(-4000)), stdout: redactText(stdout.slice(-1000)), signal: result.signal } });
|
const failureKind = timedOut ? "backend-timeout" : "infra-failed";
|
||||||
|
throw new AgentRunError(failureKind, `git ${args[0] ?? "command"} ${timedOut ? `timed out after ${timeoutMs}ms` : `failed with code ${result.code}`}`, {
|
||||||
|
httpStatus: timedOut ? 504 : 502,
|
||||||
|
details: {
|
||||||
|
operation: options.operation ?? args[0] ?? "git",
|
||||||
|
repoUrl: options.repoUrl ?? null,
|
||||||
|
fetchRepoUrl: options.fetchRepoUrl ?? null,
|
||||||
|
timeoutMs,
|
||||||
|
elapsedMs: Date.now() - startedAt,
|
||||||
|
protocol: defaultGitHttpVersion,
|
||||||
|
terminalPrompt: false,
|
||||||
|
credentialHelper: "gh-auth-setup-git",
|
||||||
|
proxyDecision: proxyDecisionForUrl(options.fetchRepoUrl ?? options.repoUrl ?? ""),
|
||||||
|
stderr: redactText(stderr.slice(-4000)),
|
||||||
|
stdout: redactText(stdout.slice(-1000)),
|
||||||
|
signal: timedOut ? "SIGTERM" : result.signal,
|
||||||
|
valuesPrinted: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return { stdout, stderr };
|
return { stdout, stderr };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function gitArgs(args: string[]): string[] {
|
||||||
|
return [
|
||||||
|
"-c", `http.version=${defaultGitHttpVersion}`,
|
||||||
|
"-c", `http.lowSpeedLimit=${process.env.GIT_HTTP_LOW_SPEED_LIMIT ?? String(defaultGitLowSpeedLimitBytes)}`,
|
||||||
|
"-c", `http.lowSpeedTime=${process.env.GIT_HTTP_LOW_SPEED_TIME ?? String(defaultGitLowSpeedTimeSeconds)}`,
|
||||||
|
...args,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitCommandEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||||
|
const next: NodeJS.ProcessEnv = {
|
||||||
|
...env,
|
||||||
|
GIT_TERMINAL_PROMPT: "0",
|
||||||
|
GIT_HTTP_VERSION: env.GIT_HTTP_VERSION ?? defaultGitHttpVersion,
|
||||||
|
};
|
||||||
|
const noProxy = new Set(String(next.NO_PROXY || next.no_proxy || "").split(",").map((item) => item.trim()).filter(Boolean));
|
||||||
|
for (const host of gitDirectHosts(env)) noProxy.add(host);
|
||||||
|
next.NO_PROXY = [...noProxy].join(",");
|
||||||
|
next.no_proxy = next.NO_PROXY;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitTimeoutMs(env: NodeJS.ProcessEnv): number {
|
||||||
|
const value = Number(env.AGENTRUN_GIT_DEFAULT_TIMEOUT_MS);
|
||||||
|
return Number.isFinite(value) && value > 0 ? Math.trunc(value) : defaultGitOperationTimeoutMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function proxyDecisionForUrl(url: string): JsonRecord {
|
||||||
|
let host: string | null = null;
|
||||||
|
try {
|
||||||
|
host = new URL(url).hostname;
|
||||||
|
} catch {
|
||||||
|
if (/^git@github\.com:/u.test(url.trim())) host = "github.com";
|
||||||
|
}
|
||||||
|
const directHosts = new Set(gitDirectHosts(process.env));
|
||||||
|
return {
|
||||||
|
host,
|
||||||
|
mode: host && directHosts.has(host) ? "direct-preferred" : "env-proxy",
|
||||||
|
valuesPrinted: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitDirectHosts(env: NodeJS.ProcessEnv): string[] {
|
||||||
|
const raw = env.AGENTRUN_GIT_DIRECT_HOSTS;
|
||||||
|
if (!raw || raw.trim().length === 0) return [...defaultGitDirectHosts];
|
||||||
|
return raw.split(",").map((item) => item.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveBundlePath(checkoutPath: string, relativePath: string, fieldName: string): string {
|
function resolveBundlePath(checkoutPath: string, relativePath: string, fieldName: string): string {
|
||||||
const resolved = path.resolve(checkoutPath, relativePath);
|
const resolved = path.resolve(checkoutPath, relativePath);
|
||||||
const root = path.resolve(checkoutPath);
|
const root = path.resolve(checkoutPath);
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const selfTest: SelfTestCase = async (context) => {
|
|||||||
assertRunnerJobUsesToolCredentialVolume(rendered, "agentrun-v01-tool-github-ssh", "/home/agentrun/.ssh", ["id_ed25519", "known_hosts", "config"]);
|
assertRunnerJobUsesToolCredentialVolume(rendered, "agentrun-v01-tool-github-ssh", "/home/agentrun/.ssh", ["id_ed25519", "known_hosts", "config"]);
|
||||||
assertRunnerJobUsesGithubSshCommand(rendered.manifest as JsonRecord, "/home/agentrun/.ssh");
|
assertRunnerJobUsesGithubSshCommand(rendered.manifest as JsonRecord, "/home/agentrun/.ssh");
|
||||||
assertRunnerJobUsesG14EgressProxy(rendered.manifest as JsonRecord);
|
assertRunnerJobUsesG14EgressProxy(rendered.manifest as JsonRecord);
|
||||||
|
assertRunnerJobUsesBoundedGitTransport(rendered);
|
||||||
assert.equal(runnerEnvValue(rendered.manifest as JsonRecord, "AGENTRUN_CODEX_SHELL_SANDBOX"), "danger-full-access");
|
assert.equal(runnerEnvValue(rendered.manifest as JsonRecord, "AGENTRUN_CODEX_SHELL_SANDBOX"), "danger-full-access");
|
||||||
assert.equal(runnerEnvValue(rendered.manifest as JsonRecord, "HWLAB_API_KEY"), "REDACTED");
|
assert.equal(runnerEnvValue(rendered.manifest as JsonRecord, "HWLAB_API_KEY"), "REDACTED");
|
||||||
assert.deepEqual((((rendered.transientEnv as JsonRecord).names) as string[]), ["HWLAB_API_KEY"]);
|
assert.deepEqual((((rendered.transientEnv as JsonRecord).names) as string[]), ["HWLAB_API_KEY"]);
|
||||||
@@ -230,6 +231,7 @@ process.exit(1);
|
|||||||
const manifest = JSON.parse(await readFile(createdManifest, "utf8")) as JsonRecord;
|
const manifest = JSON.parse(await readFile(createdManifest, "utf8")) as JsonRecord;
|
||||||
assert.equal((manifest.spec as JsonRecord).ttlSecondsAfterFinished, 86_400);
|
assert.equal((manifest.spec as JsonRecord).ttlSecondsAfterFinished, 86_400);
|
||||||
assertRunnerJobUsesG14EgressProxy(manifest);
|
assertRunnerJobUsesG14EgressProxy(manifest);
|
||||||
|
assertRunnerJobUsesBoundedGitTransport({ manifest, gitTransport: (created as JsonRecord).gitTransport } as JsonRecord);
|
||||||
assertRunnerJobUsesTransientEnvSecret(manifest, "HWLAB_API_KEY", String(transientEnvSecret.name));
|
assertRunnerJobUsesTransientEnvSecret(manifest, "HWLAB_API_KEY", String(transientEnvSecret.name));
|
||||||
assertRunnerJobUsesTransientEnvSecret(manifest, "HWLAB_RUNTIME_API_URL", String(transientEnvSecret.name));
|
assertRunnerJobUsesTransientEnvSecret(manifest, "HWLAB_RUNTIME_API_URL", String(transientEnvSecret.name));
|
||||||
assertRunnerJobUsesTransientEnvSecret(manifest, "HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME", String(transientEnvSecret.name));
|
assertRunnerJobUsesTransientEnvSecret(manifest, "HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME", String(transientEnvSecret.name));
|
||||||
@@ -249,6 +251,7 @@ process.exit(1);
|
|||||||
const defaultEndpointSecret = defaultEndpointCreated.transientEnvSecret as JsonRecord;
|
const defaultEndpointSecret = defaultEndpointCreated.transientEnvSecret as JsonRecord;
|
||||||
assertRunnerJobUsesTransientEnvSecret(defaultEndpointManifest, "UNIDESK_MAIN_SERVER_IP", String(defaultEndpointSecret.name));
|
assertRunnerJobUsesTransientEnvSecret(defaultEndpointManifest, "UNIDESK_MAIN_SERVER_IP", String(defaultEndpointSecret.name));
|
||||||
assertRunnerJobUsesG14EgressProxy(defaultEndpointManifest);
|
assertRunnerJobUsesG14EgressProxy(defaultEndpointManifest);
|
||||||
|
assertRunnerJobUsesBoundedGitTransport({ manifest: defaultEndpointManifest, gitTransport: defaultEndpointCreated.gitTransport } as JsonRecord);
|
||||||
assertRunnerJobUsesToolCredential({ manifest: defaultEndpointManifest, toolCredentials: defaultEndpointCreated.toolCredentials } as JsonRecord, "UNIDESK_SSH_CLIENT_TOKEN", "agentrun-v01-tool-unidesk-ssh", "UNIDESK_SSH_CLIENT_TOKEN");
|
assertRunnerJobUsesToolCredential({ manifest: defaultEndpointManifest, toolCredentials: defaultEndpointCreated.toolCredentials } as JsonRecord, "UNIDESK_SSH_CLIENT_TOKEN", "agentrun-v01-tool-unidesk-ssh", "UNIDESK_SSH_CLIENT_TOKEN");
|
||||||
assertNoSecretLeak(defaultEndpointCreated);
|
assertNoSecretLeak(defaultEndpointCreated);
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
@@ -304,7 +307,7 @@ process.exit(1);
|
|||||||
assert.equal(envMap.get("AGENTRUN_SESSION_PVC_NAMESPACE"), "agentrun-v01");
|
assert.equal(envMap.get("AGENTRUN_SESSION_PVC_NAMESPACE"), "agentrun-v01");
|
||||||
assert.equal(envMap.get("AGENTRUN_SESSION_PVC_MOUNT_PATH"), "/home/agentrun/.codex-codex/sessions");
|
assert.equal(envMap.get("AGENTRUN_SESSION_PVC_MOUNT_PATH"), "/home/agentrun/.codex-codex/sessions");
|
||||||
assert.equal(envMap.get("AGENTRUN_CODEX_ROLLOUT_SUBDIR"), "sessions");
|
assert.equal(envMap.get("AGENTRUN_CODEX_ROLLOUT_SUBDIR"), "sessions");
|
||||||
return { name: "runner-k8s-job", tests: ["runner-k8s-job-dry-run", "runner-k8s-job-codex-shell-sandbox-env", "runner-k8s-job-g14-egress-proxy-env", "runner-k8s-job-deepseek-profile-dry-run", "runner-k8s-job-minimax-m3-profile-dry-run", "runner-k8s-job-dsflash-go-profile-dry-run", "runner-k8s-job-dsflash-go-legacy-secretref-normalized", "runner-k8s-job-create-api", "runner-k8s-job-retention-ttl", "runner-job-transient-env", "runner-job-transient-env-secretref", "runner-job-tool-credential-env", "runner-job-unidesk-ssh-tool-credential-env", "runner-job-tool-credential-volume", "runner-job-unidesk-ssh-endpoint-auto-env", "runner-job-unidesk-ssh-transient-env-denied", "runner-k8s-job-session-pvc-volume-and-env"] };
|
return { name: "runner-k8s-job", tests: ["runner-k8s-job-dry-run", "runner-k8s-job-codex-shell-sandbox-env", "runner-k8s-job-g14-egress-proxy-env", "runner-k8s-job-bounded-git-transport-env", "runner-k8s-job-deepseek-profile-dry-run", "runner-k8s-job-minimax-m3-profile-dry-run", "runner-k8s-job-dsflash-go-profile-dry-run", "runner-k8s-job-dsflash-go-legacy-secretref-normalized", "runner-k8s-job-create-api", "runner-k8s-job-retention-ttl", "runner-job-transient-env", "runner-job-transient-env-secretref", "runner-job-tool-credential-env", "runner-job-unidesk-ssh-tool-credential-env", "runner-job-tool-credential-volume", "runner-job-unidesk-ssh-endpoint-auto-env", "runner-job-unidesk-ssh-transient-env-denied", "runner-k8s-job-session-pvc-volume-and-env"] };
|
||||||
} finally {
|
} finally {
|
||||||
await new Promise<void>((resolve) => server.server.close(() => resolve()));
|
await new Promise<void>((resolve) => server.server.close(() => resolve()));
|
||||||
}
|
}
|
||||||
@@ -364,6 +367,25 @@ function assertRunnerJobUsesG14EgressProxy(manifest: JsonRecord): void {
|
|||||||
assert.ok(noProxy.includes(".svc"), "NO_PROXY must include Kubernetes Service domains");
|
assert.ok(noProxy.includes(".svc"), "NO_PROXY must include Kubernetes Service domains");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertRunnerJobUsesBoundedGitTransport(rendered: JsonRecord): void {
|
||||||
|
const manifest = rendered.manifest as JsonRecord;
|
||||||
|
assert.equal(runnerEnvValue(manifest, "GIT_TERMINAL_PROMPT"), "0");
|
||||||
|
assert.equal(runnerEnvValue(manifest, "GIT_HTTP_VERSION"), "HTTP/1.1");
|
||||||
|
assert.equal(runnerEnvValue(manifest, "GIT_HTTP_LOW_SPEED_LIMIT"), "1024");
|
||||||
|
assert.equal(runnerEnvValue(manifest, "GIT_HTTP_LOW_SPEED_TIME"), "15");
|
||||||
|
assert.equal(runnerEnvValue(manifest, "AGENTRUN_GIT_DEFAULT_TIMEOUT_MS"), "60000");
|
||||||
|
assert.equal(runnerEnvValue(manifest, "AGENTRUN_GIT_CREDENTIAL_HELPER"), "gh-auth-setup-git");
|
||||||
|
const directHosts = String(runnerEnvValue(manifest, "AGENTRUN_GIT_DIRECT_HOSTS"));
|
||||||
|
assert.ok(directHosts.includes("github.com"), "GitHub HTTPS transport should be eligible for direct fallback");
|
||||||
|
assert.ok(directHosts.includes("codeload.github.com"), "codeload downloads should be eligible for direct fallback");
|
||||||
|
const summary = rendered.gitTransport as JsonRecord;
|
||||||
|
assert.equal(summary.valuesPrinted, false);
|
||||||
|
assert.equal(summary.terminalPrompt, false);
|
||||||
|
assert.equal(summary.defaultTimeoutMs, 60_000);
|
||||||
|
assert.equal(summary.httpVersion, "HTTP/1.1");
|
||||||
|
assert.equal(summary.credentialHelper, "gh-auth-setup-git");
|
||||||
|
}
|
||||||
|
|
||||||
function assertRunnerJobUsesGithubSshCommand(manifest: JsonRecord, mountPath: string): void {
|
function assertRunnerJobUsesGithubSshCommand(manifest: JsonRecord, mountPath: string): void {
|
||||||
const value = String(runnerEnvValue(manifest, "GIT_SSH_COMMAND"));
|
const value = String(runnerEnvValue(manifest, "GIT_SSH_COMMAND"));
|
||||||
assert.ok(value.includes(`-F ${mountPath}/config`), "GIT_SSH_COMMAND must use the mounted SSH config");
|
assert.ok(value.includes(`-F ${mountPath}/config`), "GIT_SSH_COMMAND must use the mounted SSH config");
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ if (args[0] === "get" && args[1] === "secret") {
|
|||||||
console.log(JSON.stringify(fixture));
|
console.log(JSON.stringify(fixture));
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
if (args[0] === "get" && args[1] === "pvc") {
|
||||||
|
console.error('Error from server (NotFound): persistentvolumeclaims "' + args[2] + '" not found');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
if (args[0] === "get" && args[1] === "secrets") {
|
if (args[0] === "get" && args[1] === "secrets") {
|
||||||
const items = [];
|
const items = [];
|
||||||
if (!(await Bun.file(deletedMarkerPath("agentrun-v01-provider-codex")).exists())) items.push(fixtureSecret("agentrun-v01-provider-codex"));
|
if (!(await Bun.file(deletedMarkerPath("agentrun-v01-provider-codex")).exists())) items.push(fixtureSecret("agentrun-v01-provider-codex"));
|
||||||
@@ -105,6 +109,10 @@ if (args[0] === "replace") {
|
|||||||
if (args[0] === "create") {
|
if (args[0] === "create") {
|
||||||
const text = await readStdin();
|
const text = await readStdin();
|
||||||
const manifest = JSON.parse(text);
|
const manifest = JSON.parse(text);
|
||||||
|
if (manifest.kind === "PersistentVolumeClaim") {
|
||||||
|
console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kind, metadata: { name: manifest.metadata.name, namespace: manifest.metadata.namespace, resourceVersion: "rv-pvc" }, status: { phase: "Bound" } }));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
if (manifest.kind === "Secret") {
|
if (manifest.kind === "Secret") {
|
||||||
try { rmSync(deletedMarkerPath(manifest.metadata.name)); } catch {}
|
try { rmSync(deletedMarkerPath(manifest.metadata.name)); } catch {}
|
||||||
const annotations = manifest.metadata?.annotations ?? {};
|
const annotations = manifest.metadata?.annotations ?? {};
|
||||||
|
|||||||
@@ -22,12 +22,29 @@ const selfTest: SelfTestCase = async (context) => {
|
|||||||
const fakeKubectl = path.join(context.tmp, "fake-kubectl-hwlab.js");
|
const fakeKubectl = path.join(context.tmp, "fake-kubectl-hwlab.js");
|
||||||
const createdManifest = path.join(context.tmp, "created-hwlab-runner-job.json");
|
const createdManifest = path.join(context.tmp, "created-hwlab-runner-job.json");
|
||||||
await writeFile(fakeKubectl, `#!/usr/bin/env bun
|
await writeFile(fakeKubectl, `#!/usr/bin/env bun
|
||||||
const chunks = [];
|
const args = Bun.argv.slice(2);
|
||||||
for await (const chunk of Bun.stdin.stream()) chunks.push(chunk);
|
async function readStdin() {
|
||||||
const text = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString("utf8");
|
const chunks = [];
|
||||||
await Bun.write(${JSON.stringify(createdManifest)}, text);
|
for await (const chunk of Bun.stdin.stream()) chunks.push(chunk);
|
||||||
const manifest = JSON.parse(text);
|
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString("utf8");
|
||||||
console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kind, metadata: { uid: "job-uid-hwlab", resourceVersion: "1", name: manifest.metadata.name, namespace: manifest.metadata.namespace } }));
|
}
|
||||||
|
if (args[0] === "get" && args[1] === "pvc") {
|
||||||
|
console.error('Error from server (NotFound): persistentvolumeclaims "' + args[2] + '" not found');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (args[0] === "create") {
|
||||||
|
const text = await readStdin();
|
||||||
|
const manifest = JSON.parse(text);
|
||||||
|
if (manifest.kind === "PersistentVolumeClaim") {
|
||||||
|
console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kind, metadata: { uid: "pvc-uid-hwlab", resourceVersion: "1", name: manifest.metadata.name, namespace: manifest.metadata.namespace }, status: { phase: "Bound" } }));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
await Bun.write(${JSON.stringify(createdManifest)}, text);
|
||||||
|
console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kind, metadata: { uid: "job-uid-hwlab", resourceVersion: "1", name: manifest.metadata.name, namespace: manifest.metadata.namespace } }));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
console.error("unsupported fake kubectl args: " + args.join(" "));
|
||||||
|
process.exit(1);
|
||||||
`);
|
`);
|
||||||
await chmod(fakeKubectl, 0o755);
|
await chmod(fakeKubectl, 0o755);
|
||||||
const store = new MemoryAgentRunStore();
|
const store = new MemoryAgentRunStore();
|
||||||
@@ -97,7 +114,7 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
|
|||||||
const resultBundleTargets = (((resultEnvelope.resourceBundleRef as JsonRecord).bundles as JsonRecord).items as JsonRecord[]).map((item) => item.targetPath);
|
const resultBundleTargets = (((resultEnvelope.resourceBundleRef as JsonRecord).bundles as JsonRecord).items as JsonRecord[]).map((item) => item.targetPath);
|
||||||
assert.deepEqual(resultBundleTargets, ["tools", ".agents/skills"]);
|
assert.deepEqual(resultBundleTargets, ["tools", ".agents/skills"]);
|
||||||
const materialized = ((resultEnvelope.resourceBundleRef as JsonRecord).materialized as JsonRecord);
|
const materialized = ((resultEnvelope.resourceBundleRef as JsonRecord).materialized as JsonRecord);
|
||||||
assert.deepEqual(((materialized.tools as JsonRecord).names), ["apply_patch", "hwpod", "tran", "trans"]);
|
assert.deepEqual(((materialized.tools as JsonRecord).names), ["agentrun-git", "apply_patch", "hwpod", "tran", "trans"]);
|
||||||
assert.equal(((materialized.tools as JsonRecord).installed), true);
|
assert.equal(((materialized.tools as JsonRecord).installed), true);
|
||||||
assert.deepEqual(((materialized.skillDirs as JsonRecord).names), ["dad-dev", "hwpod-cli", "hwpod-ctl"]);
|
assert.deepEqual(((materialized.skillDirs as JsonRecord).names), ["dad-dev", "hwpod-cli", "hwpod-ctl"]);
|
||||||
const requiredSkillItems = ((materialized.requiredSkills as JsonRecord).items as JsonRecord[]);
|
const requiredSkillItems = ((materialized.requiredSkills as JsonRecord).items as JsonRecord[]);
|
||||||
@@ -278,6 +295,7 @@ async function createLocalGitBundle(context: SelfTestContext, repoName = "bundle
|
|||||||
await writeFile(path.join(repo, "tools", "tran"), "#!/usr/bin/env sh\necho tran-selftest\n", "utf8");
|
await writeFile(path.join(repo, "tools", "tran"), "#!/usr/bin/env sh\necho tran-selftest\n", "utf8");
|
||||||
await writeFile(path.join(repo, "tools", "trans"), "#!/usr/bin/env sh\necho trans-selftest\n", "utf8");
|
await writeFile(path.join(repo, "tools", "trans"), "#!/usr/bin/env sh\necho trans-selftest\n", "utf8");
|
||||||
await writeFile(path.join(repo, "tools", "apply_patch"), "#!/usr/bin/env sh\necho apply-patch-selftest\n", "utf8");
|
await writeFile(path.join(repo, "tools", "apply_patch"), "#!/usr/bin/env sh\necho apply-patch-selftest\n", "utf8");
|
||||||
|
await writeFile(path.join(repo, "tools", "agentrun-git"), "#!/usr/bin/env sh\necho agentrun-git-selftest\n", "utf8");
|
||||||
await writeFile(path.join(repo, "tools", "hwpod-cli.ts"), "import { hwpodSelftestName } from './src/hwpod-harness-lib.ts';\nconsole.log(JSON.stringify({ ok: true, cli: hwpodSelftestName(), argv: process.argv.slice(2) }));\n", "utf8");
|
await writeFile(path.join(repo, "tools", "hwpod-cli.ts"), "import { hwpodSelftestName } from './src/hwpod-harness-lib.ts';\nconsole.log(JSON.stringify({ ok: true, cli: hwpodSelftestName(), argv: process.argv.slice(2) }));\n", "utf8");
|
||||||
await writeFile(path.join(repo, "tools", "src", "hwpod-harness-lib.ts"), "export function hwpodSelftestName() { return 'hwpod-selftest'; }\n", "utf8");
|
await writeFile(path.join(repo, "tools", "src", "hwpod-harness-lib.ts"), "export function hwpodSelftestName() { return 'hwpod-selftest'; }\n", "utf8");
|
||||||
await writeFile(path.join(repo, "tools", "hwpod-node.test.ts"), "console.log('test-only source file without shebang');\n", "utf8");
|
await writeFile(path.join(repo, "tools", "hwpod-node.test.ts"), "console.log('test-only source file without shebang');\n", "utf8");
|
||||||
@@ -318,7 +336,7 @@ async function createLocalGitBundle(context: SelfTestContext, repoName = "bundle
|
|||||||
"Use hwpod-ctl for HWPOD runtime inspection and control-plane state.",
|
"Use hwpod-ctl for HWPOD runtime inspection and control-plane state.",
|
||||||
].join("\n"), "utf8");
|
].join("\n"), "utf8");
|
||||||
await writeFile(path.join(repo, "skills", "hwpod-ctl", "scripts", "hwpod-ctl.mjs"), "console.log(JSON.stringify({ ok: true, cli: 'hwpod-ctl-skill-selftest' }));\n", "utf8");
|
await writeFile(path.join(repo, "skills", "hwpod-ctl", "scripts", "hwpod-ctl.mjs"), "console.log(JSON.stringify({ ok: true, cli: 'hwpod-ctl-skill-selftest' }));\n", "utf8");
|
||||||
await execFile("git", ["add", "README.md", "tools/hwpod", "tools/tran", "tools/trans", "tools/apply_patch", "tools/hwpod-cli.ts", "tools/src/hwpod-harness-lib.ts", "tools/hwpod-node.test.ts", "internal/agent/prompts/hwlab-v02-runtime.md", "skills/dad-dev/SKILL.md", "skills/hwpod-cli/SKILL.md", "skills/hwpod-cli/scripts/hwpod-cli.mjs", "skills/hwpod-ctl/SKILL.md", "skills/hwpod-ctl/scripts/hwpod-ctl.mjs"], { cwd: repo });
|
await execFile("git", ["add", "README.md", "tools/hwpod", "tools/tran", "tools/trans", "tools/apply_patch", "tools/agentrun-git", "tools/hwpod-cli.ts", "tools/src/hwpod-harness-lib.ts", "tools/hwpod-node.test.ts", "internal/agent/prompts/hwlab-v02-runtime.md", "skills/dad-dev/SKILL.md", "skills/hwpod-cli/SKILL.md", "skills/hwpod-cli/scripts/hwpod-cli.mjs", "skills/hwpod-ctl/SKILL.md", "skills/hwpod-ctl/scripts/hwpod-ctl.mjs"], { cwd: repo });
|
||||||
await execFile("git", ["-c", "user.email=selftest@example.invalid", "-c", "user.name=AgentRun SelfTest", "commit", "-m", "bundle selftest"], { cwd: repo });
|
await execFile("git", ["-c", "user.email=selftest@example.invalid", "-c", "user.name=AgentRun SelfTest", "commit", "-m", "bundle selftest"], { cwd: repo });
|
||||||
const { stdout } = await execFile("git", ["rev-parse", "HEAD"], { cwd: repo });
|
const { stdout } = await execFile("git", ["rev-parse", "HEAD"], { cwd: repo });
|
||||||
return { repoUrl: repo, commitId: stdout.trim(), requiredSkills: [{ name: "dad-dev" }] };
|
return { repoUrl: repo, commitId: stdout.trim(), requiredSkills: [{ name: "dad-dev" }] };
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const selfTest: SelfTestCase = async (context) => {
|
|||||||
const tran = await readFile(path.join(context.root, "tools/tran"), "utf8");
|
const tran = await readFile(path.join(context.root, "tools/tran"), "utf8");
|
||||||
const trans = await readFile(path.join(context.root, "tools/trans"), "utf8");
|
const trans = await readFile(path.join(context.root, "tools/trans"), "utf8");
|
||||||
const applyPatch = await readFile(path.join(context.root, "tools/apply_patch"), "utf8");
|
const applyPatch = await readFile(path.join(context.root, "tools/apply_patch"), "utf8");
|
||||||
|
const agentrunGit = await readFile(path.join(context.root, "tools/agentrun-git"), "utf8");
|
||||||
|
|
||||||
for (const packageName of requiredRunnerPackages) {
|
for (const packageName of requiredRunnerPackages) {
|
||||||
assert.equal(apkPackages.has(packageName), true, `runner image must install ${packageName}`);
|
assert.equal(apkPackages.has(packageName), true, `runner image must install ${packageName}`);
|
||||||
@@ -28,9 +29,12 @@ const selfTest: SelfTestCase = async (context) => {
|
|||||||
assert.equal(tran.startsWith("#!/usr/bin/env bun\n"), true, "tools/tran must be a shebang executable discovered by gitbundle tools");
|
assert.equal(tran.startsWith("#!/usr/bin/env bun\n"), true, "tools/tran must be a shebang executable discovered by gitbundle tools");
|
||||||
assert.equal(trans.startsWith("#!/bin/sh\n"), true, "tools/trans must be a shebang executable discovered by gitbundle tools");
|
assert.equal(trans.startsWith("#!/bin/sh\n"), true, "tools/trans must be a shebang executable discovered by gitbundle tools");
|
||||||
assert.equal(applyPatch.startsWith("#!/bin/sh\n"), true, "tools/apply_patch must be a shebang helper copied with runner tools");
|
assert.equal(applyPatch.startsWith("#!/bin/sh\n"), true, "tools/apply_patch must be a shebang helper copied with runner tools");
|
||||||
|
assert.equal(agentrunGit.startsWith("#!/usr/bin/env bun\n"), true, "tools/agentrun-git must be a shebang executable discovered by gitbundle tools");
|
||||||
assert.equal(tran.includes("UNIDESK_SSH_CLIENT_TOKEN"), true, "tools/tran must require the scoped UniDesk SSH client token");
|
assert.equal(tran.includes("UNIDESK_SSH_CLIENT_TOKEN"), true, "tools/tran must require the scoped UniDesk SSH client token");
|
||||||
assert.equal(tran.includes("/ws/ssh"), true, "tools/tran must use the frontend SSH WebSocket path");
|
assert.equal(tran.includes("/ws/ssh"), true, "tools/tran must use the frontend SSH WebSocket path");
|
||||||
assert.equal(tran.includes("apply-patch < patch.diff"), true, "tools/tran help must advertise runner-side apply-patch");
|
assert.equal(tran.includes("apply-patch < patch.diff"), true, "tools/tran help must advertise runner-side apply-patch");
|
||||||
|
assert.equal(agentrunGit.includes("GIT_TERMINAL_PROMPT"), true, "tools/agentrun-git must disable interactive git prompts");
|
||||||
|
assert.equal(agentrunGit.includes("AGENTRUN_GIT_DIRECT_HOSTS"), true, "tools/agentrun-git must expose direct host policy");
|
||||||
|
|
||||||
const help = await execFileAsync(path.join(context.root, "tools/tran"), ["--help"], { cwd: context.root, timeout: 10_000 });
|
const help = await execFileAsync(path.join(context.root, "tools/tran"), ["--help"], { cwd: context.root, timeout: 10_000 });
|
||||||
const parsed = JSON.parse(help.stdout) as { ok?: boolean; supported?: string[]; unsupported?: string[]; valuesPrinted?: boolean };
|
const parsed = JSON.parse(help.stdout) as { ok?: boolean; supported?: string[]; unsupported?: string[]; valuesPrinted?: boolean };
|
||||||
@@ -41,10 +45,18 @@ const selfTest: SelfTestCase = async (context) => {
|
|||||||
assert.equal(parsed.unsupported?.includes("apply-patch"), false);
|
assert.equal(parsed.unsupported?.includes("apply-patch"), false);
|
||||||
const patchHelp = await execFileAsync(path.join(context.root, "tools/apply_patch"), ["--help"], { cwd: context.root, timeout: 10_000 });
|
const patchHelp = await execFileAsync(path.join(context.root, "tools/apply_patch"), ["--help"], { cwd: context.root, timeout: 10_000 });
|
||||||
assert.equal(patchHelp.stdout.includes("reads *** Begin Patch format"), true);
|
assert.equal(patchHelp.stdout.includes("reads *** Begin Patch format"), true);
|
||||||
|
const agentrunGitHelp = await execFileAsync(path.join(context.root, "tools/agentrun-git"), ["--help"], { cwd: context.root, timeout: 10_000 });
|
||||||
|
const parsedGitHelp = JSON.parse(agentrunGitHelp.stdout) as { ok?: boolean; commands?: string[]; defaults?: { valuesPrinted?: boolean }; valuesPrinted?: boolean };
|
||||||
|
assert.equal(parsedGitHelp.ok, true);
|
||||||
|
assert.equal(parsedGitHelp.valuesPrinted, false);
|
||||||
|
assert.equal(parsedGitHelp.defaults?.valuesPrinted, false);
|
||||||
|
assert.equal(parsedGitHelp.commands?.includes("ls-remote"), true);
|
||||||
|
assert.equal(parsedGitHelp.commands?.includes("download"), true);
|
||||||
const summary = staticWorkReadyCapabilitySummary();
|
const summary = staticWorkReadyCapabilitySummary();
|
||||||
assert.equal(summary.valuesPrinted, false);
|
assert.equal(summary.valuesPrinted, false);
|
||||||
assert.ok((summary.requiredImageTools as string[]).includes("npm"), "work-ready capability must include npm");
|
assert.ok((summary.requiredImageTools as string[]).includes("npm"), "work-ready capability must include npm");
|
||||||
assert.ok((summary.requiredImageTools as string[]).includes("gh"), "work-ready capability must include gh");
|
assert.ok((summary.requiredImageTools as string[]).includes("gh"), "work-ready capability must include gh");
|
||||||
|
assert.ok((summary.requiredBundledTools as string[]).includes("agentrun-git"), "work-ready capability must include agentrun-git");
|
||||||
assert.equal(((summary.dependencyStrategy as { projectDependencies?: unknown }).projectDependencies), "not-installed-by-default");
|
assert.equal(((summary.dependencyStrategy as { projectDependencies?: unknown }).projectDependencies), "not-installed-by-default");
|
||||||
const fakeBin = await createFakeToolBin(path.join(context.tmp, "work-ready-bin"));
|
const fakeBin = await createFakeToolBin(path.join(context.tmp, "work-ready-bin"));
|
||||||
const smokeEnv = { PATH: fakeBin, AGENTRUN_RESOURCE_BIN_PATH: path.join(context.root, "tools") };
|
const smokeEnv = { PATH: fakeBin, AGENTRUN_RESOURCE_BIN_PATH: path.join(context.root, "tools") };
|
||||||
@@ -62,7 +74,7 @@ const selfTest: SelfTestCase = async (context) => {
|
|||||||
assert.equal(isDigestPinnedImage("127.0.0.1:5000/agentrun/agentrun-mgr@sha256:1111111111111111111111111111111111111111111111111111111111111111"), true);
|
assert.equal(isDigestPinnedImage("127.0.0.1:5000/agentrun/agentrun-mgr@sha256:1111111111111111111111111111111111111111111111111111111111111111"), true);
|
||||||
assert.equal(isDigestPinnedImage("127.0.0.1:5000/agentrun/agentrun-mgr:self-test"), false);
|
assert.equal(isDigestPinnedImage("127.0.0.1:5000/agentrun/agentrun-mgr:self-test"), false);
|
||||||
|
|
||||||
return { name: "90-runner-image-tools", tests: ["runner image installs required CLI tools", "runner image build verifies work-ready tools", "gitbundle tran tools are executable and documented", "runner apply-patch helper is bundled", "work-ready smoke runs without printing secrets", "aipod imageRef validates env image source identity"] };
|
return { name: "90-runner-image-tools", tests: ["runner image installs required CLI tools", "runner image build verifies work-ready tools", "gitbundle tran tools are executable and documented", "runner apply-patch helper is bundled", "runner agentrun-git helper is bundled", "work-ready smoke runs without printing secrets", "aipod imageRef validates env image source identity"] };
|
||||||
};
|
};
|
||||||
|
|
||||||
async function createFakeToolBin(dir: string): Promise<string> {
|
async function createFakeToolBin(dir: string): Promise<string> {
|
||||||
|
|||||||
Executable
+343
@@ -0,0 +1,343 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdir, stat } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const defaultTimeoutMs = Number(process.env.AGENTRUN_GIT_DEFAULT_TIMEOUT_MS || 60_000);
|
||||||
|
const defaultConnectTimeoutSeconds = Number(process.env.AGENTRUN_GIT_CONNECT_TIMEOUT_SECONDS || 10);
|
||||||
|
const defaultLowSpeedLimit = Number(process.env.GIT_HTTP_LOW_SPEED_LIMIT || 1_024);
|
||||||
|
const defaultLowSpeedTime = Number(process.env.GIT_HTTP_LOW_SPEED_TIME || 15);
|
||||||
|
const defaultHttpVersion = process.env.AGENTRUN_GIT_HTTP_VERSION || process.env.GIT_HTTP_VERSION || "HTTP/1.1";
|
||||||
|
const defaultDirectHosts = ["github.com", "api.github.com", "codeload.github.com", "objects.githubusercontent.com", "raw.githubusercontent.com", "registry.npmjs.org", "registry.npmmirror.com"];
|
||||||
|
|
||||||
|
function help() {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
tool: "agentrun-git",
|
||||||
|
purpose: "bounded GitHub/codeload clone, fetch, ls-remote and download helper for AgentRun runners",
|
||||||
|
commands: ["ls-remote", "clone", "fetch", "download"],
|
||||||
|
usage: [
|
||||||
|
"agentrun-git ls-remote <repo-url> [ref] [--timeout-ms N]",
|
||||||
|
"agentrun-git clone <repo-url> <target-dir> [--ref REF] [--depth N] [--timeout-ms N]",
|
||||||
|
"agentrun-git fetch <repo-dir> <ref> [--remote origin] [--depth N] [--timeout-ms N]",
|
||||||
|
"agentrun-git download <url> <output-file> [--timeout-ms N]",
|
||||||
|
],
|
||||||
|
defaults: transportDefaults(),
|
||||||
|
valuesPrinted: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transportDefaults() {
|
||||||
|
return {
|
||||||
|
timeoutMs: defaultTimeoutMs,
|
||||||
|
connectTimeoutSeconds: defaultConnectTimeoutSeconds,
|
||||||
|
httpVersion: defaultHttpVersion,
|
||||||
|
lowSpeedLimitBytes: defaultLowSpeedLimit,
|
||||||
|
lowSpeedTimeSeconds: defaultLowSpeedTime,
|
||||||
|
credentialHelper: "gh-auth-setup-git",
|
||||||
|
directHosts: directHosts(),
|
||||||
|
valuesPrinted: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJson(value) {
|
||||||
|
process.stdout.write(`${JSON.stringify(value)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message, details = {}, code = 2) {
|
||||||
|
writeJson({ ok: false, action: "agentrun-git", failureKind: "schema-invalid", message, ...details, valuesPrinted: false });
|
||||||
|
process.exit(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const positional = [];
|
||||||
|
const flags = {};
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const arg = argv[i];
|
||||||
|
if (!arg.startsWith("--")) {
|
||||||
|
positional.push(arg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const name = arg.slice(2);
|
||||||
|
const next = argv[i + 1];
|
||||||
|
if (!next || next.startsWith("--")) flags[name] = "true";
|
||||||
|
else {
|
||||||
|
flags[name] = next;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { positional, flags };
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberFlag(flags, name, fallback) {
|
||||||
|
const raw = flags[name];
|
||||||
|
if (raw === undefined) return fallback;
|
||||||
|
const value = Number(raw);
|
||||||
|
if (!Number.isFinite(value) || value <= 0) fail(`--${name} must be a positive number`, { flag: name });
|
||||||
|
return Math.trunc(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringFlag(flags, name, fallback = null) {
|
||||||
|
const raw = flags[name];
|
||||||
|
return typeof raw === "string" && raw !== "true" && raw.trim().length > 0 ? raw.trim() : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function directHosts() {
|
||||||
|
const raw = process.env.AGENTRUN_GIT_DIRECT_HOSTS;
|
||||||
|
if (!raw || raw.trim().length === 0) return [...defaultDirectHosts];
|
||||||
|
return raw.split(",").map((item) => item.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostFromTarget(value) {
|
||||||
|
const raw = String(value || "").trim();
|
||||||
|
if (/^git@github\.com:/u.test(raw)) return "github.com";
|
||||||
|
try {
|
||||||
|
return new URL(raw).hostname;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function proxyDecision(target) {
|
||||||
|
const host = hostFromTarget(target);
|
||||||
|
const hosts = directHosts();
|
||||||
|
const direct = host ? hosts.includes(host) : false;
|
||||||
|
const proxyConfigured = Boolean(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.ALL_PROXY || process.env.http_proxy || process.env.https_proxy || process.env.all_proxy);
|
||||||
|
return {
|
||||||
|
host,
|
||||||
|
mode: direct ? "direct-no-proxy" : proxyConfigured ? "env-proxy" : "direct-no-proxy",
|
||||||
|
reason: direct ? "AGENTRUN_GIT_DIRECT_HOSTS" : proxyConfigured ? "proxy-env" : "no-proxy-env",
|
||||||
|
proxyConfigured,
|
||||||
|
valuesPrinted: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandEnv(target) {
|
||||||
|
const decision = proxyDecision(target);
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
GIT_TERMINAL_PROMPT: "0",
|
||||||
|
GIT_HTTP_VERSION: defaultHttpVersion,
|
||||||
|
GIT_HTTP_LOW_SPEED_LIMIT: String(defaultLowSpeedLimit),
|
||||||
|
GIT_HTTP_LOW_SPEED_TIME: String(defaultLowSpeedTime),
|
||||||
|
};
|
||||||
|
if (decision.mode === "direct-no-proxy") {
|
||||||
|
delete env.HTTP_PROXY;
|
||||||
|
delete env.HTTPS_PROXY;
|
||||||
|
delete env.ALL_PROXY;
|
||||||
|
delete env.http_proxy;
|
||||||
|
delete env.https_proxy;
|
||||||
|
delete env.all_proxy;
|
||||||
|
}
|
||||||
|
const noProxy = new Set(String(env.NO_PROXY || env.no_proxy || "").split(",").map((item) => item.trim()).filter(Boolean));
|
||||||
|
for (const host of directHosts()) noProxy.add(host);
|
||||||
|
if (decision.host) noProxy.add(decision.host);
|
||||||
|
env.NO_PROXY = [...noProxy].join(",");
|
||||||
|
env.no_proxy = env.NO_PROXY;
|
||||||
|
return { env, decision };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCommand(command, args, options) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const child = spawn(command, args, { cwd: options.cwd, env: options.env, stdio: ["ignore", "pipe", "pipe"] });
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
child.stdout.setEncoding("utf8");
|
||||||
|
child.stderr.setEncoding("utf8");
|
||||||
|
child.stdout.on("data", (chunk) => { stdout = bounded(`${stdout}${chunk}`); });
|
||||||
|
child.stderr.on("data", (chunk) => { stderr = bounded(`${stderr}${chunk}`); });
|
||||||
|
let timedOut = false;
|
||||||
|
let closed = false;
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!closed) child.kill("SIGKILL");
|
||||||
|
}, 2_000).unref();
|
||||||
|
}, options.timeoutMs);
|
||||||
|
const result = await new Promise((resolve, reject) => {
|
||||||
|
child.on("error", reject);
|
||||||
|
child.on("close", (code, signal) => {
|
||||||
|
closed = true;
|
||||||
|
resolve({ code, signal });
|
||||||
|
});
|
||||||
|
}).catch((error) => ({ code: 127, signal: null, spawnError: error instanceof Error ? error.message : String(error) }));
|
||||||
|
clearTimeout(timeout);
|
||||||
|
return {
|
||||||
|
code: result.code,
|
||||||
|
signal: timedOut ? "SIGTERM" : result.signal,
|
||||||
|
timedOut,
|
||||||
|
elapsedMs: Date.now() - startedAt,
|
||||||
|
stdout: redact(stdout),
|
||||||
|
stderr: redact(stderr),
|
||||||
|
spawnError: result.spawnError ? redact(result.spawnError) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bounded(text) {
|
||||||
|
const limit = 12_000;
|
||||||
|
return text.length > limit ? text.slice(text.length - limit) : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function redact(text) {
|
||||||
|
return String(text)
|
||||||
|
.replace(/\b(ghp_[A-Za-z0-9_]{8,}|github_pat_[A-Za-z0-9_]+|sk-[A-Za-z0-9_-]{8,})\b/gu, "REDACTED")
|
||||||
|
.replace(/(authorization\s*[:=]\s*)(bearer\s+)?[A-Za-z0-9._~+/=-]+/giu, "$1$2REDACTED")
|
||||||
|
.replace(/(https?:\/\/[^:\s/@]+:)[^@\s]+(@)/giu, "$1REDACTED$2");
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitArgs(args) {
|
||||||
|
return [
|
||||||
|
"-c", `http.version=${defaultHttpVersion}`,
|
||||||
|
"-c", `http.lowSpeedLimit=${defaultLowSpeedLimit}`,
|
||||||
|
"-c", `http.lowSpeedTime=${defaultLowSpeedTime}`,
|
||||||
|
...args,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupCredentialHelper(env, timeoutMs) {
|
||||||
|
const tokenPresent = Boolean(env.GH_TOKEN || env.GITHUB_TOKEN);
|
||||||
|
if (!tokenPresent) return { helper: "gh-auth-setup-git", tokenPresent: false, status: "skipped-no-token", valuesPrinted: false };
|
||||||
|
const result = await runCommand("gh", ["auth", "setup-git"], { env, timeoutMs: Math.min(timeoutMs, 15_000) });
|
||||||
|
return { helper: "gh-auth-setup-git", tokenPresent: true, status: result.code === 0 ? "configured" : "failed", exitCode: result.code, stderrTail: result.stderr.slice(-500), valuesPrinted: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeoutBudget(timeoutMs, elapsedMs, timedOut) {
|
||||||
|
return { timeoutMs, elapsedMs, state: timedOut ? "timed-out" : "terminal", valuesPrinted: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathSummary(value) {
|
||||||
|
const parts = path.resolve(value).split(/[\\/]+/u).filter(Boolean);
|
||||||
|
return { absolute: path.isAbsolute(value), basename: parts.at(-1) || null, depth: parts.length, valuesPrinted: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gitRevParse(cwd, env, timeoutMs, ref = "HEAD") {
|
||||||
|
const result = await runCommand("git", gitArgs(["rev-parse", ref]), { cwd, env, timeoutMs: Math.min(timeoutMs, 10_000) });
|
||||||
|
return result.code === 0 ? result.stdout.trim().split(/\s+/u)[0] || null : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function operationResult(operation, target, timeoutMs, body) {
|
||||||
|
const { env, decision } = commandEnv(target);
|
||||||
|
const credential = await setupCredentialHelper(env, timeoutMs);
|
||||||
|
const result = await body(env, decision, credential);
|
||||||
|
const ok = result.code === 0 && result.timedOut !== true;
|
||||||
|
writeJson({
|
||||||
|
ok,
|
||||||
|
action: "agentrun-git",
|
||||||
|
operation,
|
||||||
|
target: redactedTarget(target),
|
||||||
|
timeoutBudget: timeoutBudget(timeoutMs, result.elapsedMs, result.timedOut),
|
||||||
|
protocol: { httpVersion: defaultHttpVersion, terminalPrompt: false, lowSpeedLimitBytes: defaultLowSpeedLimit, lowSpeedTimeSeconds: defaultLowSpeedTime, valuesPrinted: false },
|
||||||
|
proxyDecision: decision,
|
||||||
|
credential,
|
||||||
|
result: result.result ?? {},
|
||||||
|
failureKind: ok ? null : result.timedOut ? "backend-timeout" : "infra-failed",
|
||||||
|
exitCode: result.code,
|
||||||
|
signal: result.signal,
|
||||||
|
stderrTail: result.stderr.slice(-1200),
|
||||||
|
valuesPrinted: false,
|
||||||
|
});
|
||||||
|
process.exit(ok ? 0 : result.timedOut ? 124 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactedTarget(value) {
|
||||||
|
return redact(String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lsRemote(args, flags) {
|
||||||
|
const repoUrl = args[0];
|
||||||
|
if (!repoUrl) fail("ls-remote requires <repo-url>");
|
||||||
|
const ref = args[1] || "HEAD";
|
||||||
|
const timeoutMs = numberFlag(flags, "timeout-ms", defaultTimeoutMs);
|
||||||
|
await operationResult("ls-remote", repoUrl, timeoutMs, async (env) => {
|
||||||
|
const result = await runCommand("git", gitArgs(["ls-remote", repoUrl, ref]), { env, timeoutMs });
|
||||||
|
const first = result.stdout.trim().split(/\r?\n/u)[0] || "";
|
||||||
|
return { ...result, result: { ref, firstLine: first.slice(0, 160), valuesPrinted: false } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cloneRepo(args, flags) {
|
||||||
|
const repoUrl = args[0];
|
||||||
|
const targetDir = args[1];
|
||||||
|
if (!repoUrl || !targetDir) fail("clone requires <repo-url> <target-dir>");
|
||||||
|
const timeoutMs = numberFlag(flags, "timeout-ms", defaultTimeoutMs);
|
||||||
|
const depth = String(numberFlag(flags, "depth", 1));
|
||||||
|
const ref = stringFlag(flags, "ref");
|
||||||
|
await mkdir(path.dirname(path.resolve(targetDir)), { recursive: true });
|
||||||
|
await operationResult("clone", repoUrl, timeoutMs, async (env) => {
|
||||||
|
let result;
|
||||||
|
if (ref) {
|
||||||
|
await mkdir(targetDir, { recursive: true });
|
||||||
|
const init = await runCommand("git", gitArgs(["init"]), { cwd: targetDir, env, timeoutMs: Math.min(timeoutMs, 10_000) });
|
||||||
|
if (init.code !== 0) return init;
|
||||||
|
const remote = await runCommand("git", gitArgs(["remote", "add", "origin", repoUrl]), { cwd: targetDir, env, timeoutMs: Math.min(timeoutMs, 10_000) });
|
||||||
|
if (remote.code !== 0) return remote;
|
||||||
|
result = await runCommand("git", gitArgs(["fetch", "--depth", depth, "origin", ref]), { cwd: targetDir, env, timeoutMs });
|
||||||
|
if (result.code === 0) result = await runCommand("git", gitArgs(["checkout", "--detach", "FETCH_HEAD"]), { cwd: targetDir, env, timeoutMs: Math.min(timeoutMs, 10_000) });
|
||||||
|
} else {
|
||||||
|
result = await runCommand("git", gitArgs(["clone", "--depth", depth, repoUrl, targetDir]), { env, timeoutMs });
|
||||||
|
}
|
||||||
|
const commitId = result.code === 0 ? await gitRevParse(targetDir, env, timeoutMs) : null;
|
||||||
|
return { ...result, result: { target: pathSummary(targetDir), ref, depth: Number(depth), commitId, valuesPrinted: false } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRepo(args, flags) {
|
||||||
|
const repoDir = args[0];
|
||||||
|
const ref = args[1];
|
||||||
|
if (!repoDir || !ref) fail("fetch requires <repo-dir> <ref>");
|
||||||
|
const timeoutMs = numberFlag(flags, "timeout-ms", defaultTimeoutMs);
|
||||||
|
const depth = String(numberFlag(flags, "depth", 1));
|
||||||
|
const remote = stringFlag(flags, "remote", "origin");
|
||||||
|
await operationResult("fetch", `${repoDir}:${remote}:${ref}`, timeoutMs, async (env) => {
|
||||||
|
const result = await runCommand("git", gitArgs(["fetch", "--depth", depth, remote, ref]), { cwd: repoDir, env, timeoutMs });
|
||||||
|
const fetchHead = result.code === 0 ? await gitRevParse(repoDir, env, timeoutMs, "FETCH_HEAD") : null;
|
||||||
|
return { ...result, result: { repoDir: pathSummary(repoDir), remote, ref, depth: Number(depth), fetchHead, valuesPrinted: false } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function download(args, flags) {
|
||||||
|
const url = args[0];
|
||||||
|
const output = args[1];
|
||||||
|
if (!url || !output) fail("download requires <url> <output-file>");
|
||||||
|
const timeoutMs = numberFlag(flags, "timeout-ms", defaultTimeoutMs);
|
||||||
|
const seconds = Math.max(1, Math.ceil(timeoutMs / 1000));
|
||||||
|
await mkdir(path.dirname(path.resolve(output)), { recursive: true });
|
||||||
|
await operationResult("download", url, timeoutMs, async (env) => {
|
||||||
|
const result = await runCommand("curl", ["-L", "--fail", "--max-time", String(seconds), "--connect-timeout", String(defaultConnectTimeoutSeconds), "-o", output, url], { env, timeoutMs: timeoutMs + 1_000 });
|
||||||
|
let bytes = 0;
|
||||||
|
let sha256 = null;
|
||||||
|
if (result.code === 0) {
|
||||||
|
const file = Bun.file(output);
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
bytes = buffer.length;
|
||||||
|
sha256 = createHash("sha256").update(buffer).digest("hex");
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
bytes = (await stat(output)).size;
|
||||||
|
} catch {
|
||||||
|
bytes = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...result, result: { output: pathSummary(output), bytes, sha256: sha256 ? sha256.slice(0, 16) : null, valuesPrinted: false } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
if (argv.length === 0 || argv[0] === "--help" || argv[0] === "help" || argv[0] === "-h") {
|
||||||
|
writeJson(help());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const command = argv[0];
|
||||||
|
const { positional, flags } = parseArgs(argv.slice(1));
|
||||||
|
if (command === "ls-remote") return await lsRemote(positional, flags);
|
||||||
|
if (command === "clone") return await cloneRepo(positional, flags);
|
||||||
|
if (command === "fetch") return await fetchRepo(positional, flags);
|
||||||
|
if (command === "download") return await download(positional, flags);
|
||||||
|
fail(`unsupported command: ${command}`, { commands: help().commands });
|
||||||
|
}
|
||||||
|
|
||||||
|
await main();
|
||||||
Reference in New Issue
Block a user