Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

161 lines · 7.4 KBRaw History
1/**
2 * Diffinery runner agent entry point.
3 *
4 * agent.mjs fetch the job from DIFFINERY_API_URL and run it
5 * agent.mjs --spec job.json run a JobSpec from a file (reports to spec.apiUrl,
6 * or DIFFINERY_API_URL; `--offline` logs to stdout only)
7 * agent.mjs --sandbox AI-agent sandbox HTTP server on :8080
8 * agent.mjs --sandbox-pool many sandboxes in one process, for local development and
9 * end-to-end tests (see sandbox-pool.ts)
10 * agent.mjs --jail ... run a terminal task's command in its jail (see jail.ts)
11 * agent.mjs --registry-relay HTTPS proxy for the Docker daemon that splits big
12 * image layer uploads to the Diffinery registry (see relay.ts)
13 *
14 * With DIFFINERY_IMPORT_ID and DIFFINERY_IMPORT_TOKEN set (instead of a job), it
15 * runs a GitHub import's Git transfer (see importer.ts) and exits.
16 */
17import { readFile } from "node:fs/promises";
18import type { JobSpec } from "@diffinery/shared";
19import { UsageSampler } from "./usage.js";
20import { apiBaseFor, runAgentJob } from "./agent.js";
21import { trustEgressCa } from "./egress.js";
22import { RunnerApi } from "./api.js";
23import { runJail } from "./jail.js";
24import { runImportAgent } from "./importer.js";
25import { startSandbox } from "./sandbox.js";
26import { startSandboxPool } from "./sandbox-pool.js";
27import { startRelay } from "./relay.js";
28
29interface Args {
30 spec?: string;
31 sandbox: boolean;
32 pool: boolean;
33 relay: boolean;
34 offline: boolean;
35 workRoot?: string;
36}
37
38function parseArgs(argv: string[]): Args {
39 const a: Args = { sandbox: false, pool: false, relay: false, offline: false };
40 for (let i = 0; i < argv.length; i++) {
41 const v = argv[i]!;
42 if (v === "--spec") a.spec = argv[++i];
43 else if (v.startsWith("--spec=")) a.spec = v.slice(7);
44 else if (v === "--sandbox") a.sandbox = true;
45 else if (v === "--sandbox-pool") a.pool = true;
46 else if (v === "--registry-relay") a.relay = true;
47 else if (v === "--offline") a.offline = true;
48 else if (v === "--work-root") a.workRoot = argv[++i];
49 else if (v === "--help" || v === "-h") {
50 process.stdout.write("usage: agent.mjs [--spec job.json [--offline]] [--sandbox | --sandbox-pool] [--work-root dir]\n");
51 process.exit(0);
52 }
53 }
54 return a;
55}
56
57function fail(msg: string): never {
58 process.stderr.write(`[diffinery-runner] ${msg}\n`);
59 process.exit(2);
60}
61
62async function main(): Promise<void> {
63 if (process.argv[2] === "--jail") process.exit(await runJail(process.argv.slice(3)));
64 const args = parseArgs(process.argv.slice(2));
65 const env = process.env;
66 const workRoot = args.workRoot ?? env.DIFFINERY_WORK_ROOT ?? "/workspace";
67 await trustEgressCa(env);
68
69 if (args.relay) {
70 const { DIFFINERY_RELAY_HOST: host, DIFFINERY_RELAY_UPSTREAM: upstream, DIFFINERY_RELAY_CERT: cert, DIFFINERY_RELAY_KEY: key } = env;
71 if (!host || !upstream || !cert || !key) fail("DIFFINERY_RELAY_HOST, DIFFINERY_RELAY_UPSTREAM, DIFFINERY_RELAY_CERT and DIFFINERY_RELAY_KEY are required with --registry-relay");
72 await startRelay({ port: Number(env.DIFFINERY_RELAY_PORT ?? 3129), host, upstream, chunkSize: env.DIFFINERY_RELAY_CHUNK ? Number(env.DIFFINERY_RELAY_CHUNK) : undefined, cert: await readFile(cert, "utf8"), key: await readFile(key, "utf8") });
73 return;
74 }
75
76 if (args.pool) {
77 const secret = env.DIFFINERY_SANDBOX_POOL_SECRET;
78 if (!secret) fail("DIFFINERY_SANDBOX_POOL_SECRET is required in --sandbox-pool mode");
79 const pool = await startSandboxPool({ port: Number(env.DIFFINERY_SANDBOX_PORT ?? env.PORT ?? 8080), secret, workRoot });
80 process.stdout.write(`[sandbox-pool] listening on :${pool.port}\n`);
81 const stop = () => void pool.close().then(() => process.exit(0));
82 process.on("SIGTERM", stop);
83 process.on("SIGINT", stop);
84 return;
85 }
86 if (args.sandbox) {
87 const secret = env.DIFFINERY_SANDBOX_SECRET;
88 if (!secret) fail("DIFFINERY_SANDBOX_SECRET is required in --sandbox mode");
89 const h = await startSandbox({
90 port: Number(env.DIFFINERY_SANDBOX_PORT ?? env.PORT ?? 8080),
91 secret,
92 workRoot,
93 cloneUrl: env.DIFFINERY_CLONE_URL,
94 ref: env.DIFFINERY_REF,
95 token: env.DIFFINERY_JOB_TOKEN,
96 });
97 process.stdout.write(`[sandbox] listening on :${h.port}\n`);
98 const stop = () => void h.close().then(() => process.exit(0));
99 process.on("SIGTERM", stop);
100 process.on("SIGINT", stop);
101 return;
102 }
103
104 if (env.DIFFINERY_IMPORT_ID) {
105 const api = env.DIFFINERY_API_URL;
106 const token = env.DIFFINERY_IMPORT_TOKEN;
107 if (!api || !token) fail("DIFFINERY_API_URL and DIFFINERY_IMPORT_TOKEN are required in import mode");
108 const chunk = Number(env.DIFFINERY_IMPORT_CHUNK_BYTES);
109 const large = Number(env.DIFFINERY_IMPORT_LARGE_BLOB_BYTES);
110 const result = await runImportAgent({ apiUrl: api, importId: env.DIFFINERY_IMPORT_ID, token, workRoot, chunkBytes: Number.isFinite(chunk) && chunk > 0 ? chunk : undefined, largeBlobBytes: Number.isFinite(large) && large > 0 ? large : undefined });
111 process.stdout.write(`[diffinery-import] ${result.ok ? "finished" : `failed: ${result.error}`}\n`);
112 process.exit(result.ok ? 0 : 1);
113 }
114
115 let spec: JobSpec;
116 let apiBase: string | null;
117 if (args.spec) {
118 spec = JSON.parse(await readFile(args.spec, "utf8")) as JobSpec;
119 if (env.DIFFINERY_JOB_TOKEN && !spec.token) spec.token = env.DIFFINERY_JOB_TOKEN;
120 apiBase = args.offline ? null : apiBaseFor(env.DIFFINERY_API_URL ?? spec.apiUrl, spec.jobId);
121 } else {
122 const api = env.DIFFINERY_API_URL;
123 const jobId = env.DIFFINERY_JOB_ID;
124 const token = env.DIFFINERY_JOB_TOKEN;
125 if (!api || !jobId || !token) fail("DIFFINERY_API_URL, DIFFINERY_JOB_ID and DIFFINERY_JOB_TOKEN are required (or pass --spec / --sandbox)");
126 apiBase = apiBaseFor(api, jobId);
127 const client = new RunnerApi(apiBase, token);
128 try {
129 spec = await client.getSpec();
130 } catch (e) {
131 const msg = e instanceof Error ? e.message : String(e);
132 try {
133 await client.complete({ conclusion: "failure", outputs: {}, failure: { message: `Runner could not fetch the job spec: ${msg}` } });
134 } catch {
135 /* nothing more we can do */
136 }
137 fail(`could not fetch job spec: ${msg}`);
138 }
139 spec.token ||= token;
140 spec.apiUrl ||= api;
141 }
142
143 const completion = await runAgentJob(spec, {
144 apiBase,
145 workRoot,
146 toolCache: env.RUNNER_TOOL_CACHE,
147 echoLogs: env.DIFFINERY_ECHO_LOGS === "1" || apiBase === null,
148 handleSignals: true,
149 // Cloudflare gives a container 15 minutes after SIGTERM during a rollout; the last
150 // two are for cancelling the steps and reporting the result before SIGKILL.
151 drainOnSigtermMs: args.spec ? undefined : 13 * 60_000,
152 // A hosted runner is its own machine (Cloudflare sets the Durable Object id in its container).
153 // test/docker-wrapper.test.ts runs the image's docker wrapper with the head start on.
154 prewarmDocker: !!env.CLOUDFLARE_DURABLE_OBJECT_ID,
155 measureUsage: !!env.CLOUDFLARE_DURABLE_OBJECT_ID || env.DIFFINERY_MEASURE_USAGE === "1" ? new UsageSampler({ cores: Number(env.DIFFINERY_RUNNER_VCPU) || undefined }) : false,
156 });
157 process.stdout.write(`[diffinery-runner] job finished: ${completion.conclusion}\n`);
158 process.exit(completion.conclusion === "success" ? 0 : 1);
159}
160
161main().catch((e: unknown) => fail(e instanceof Error ? (e.stack ?? e.message) : String(e)));