Fork Horse repository, listed by its owner. Publisher snapshot.

packages/forkhorse-runner/src/index.ts

Snapshot at 34e3175

Containing directory

4,002 bytes · typescriptView raw
1import { DurableObject, RpcTarget, WorkerEntrypoint } from "cloudflare:workers";
2import { Snapshot } from "./snapshot";
3import type { QuickPorts } from "./quick";
4import { forwardGitRead } from "./git";
5import { runReviewChecks } from "./runner";
6import type { ContainerPort, ForkRepository, QuickFileStat, Receipt, ReviewAuthority, RunRequest, StoredReceipt } from "./types";
7
8export { QuickFiles } from "./snapshot";
9export { requirePassingReceipt } from "./runner";
10export type * from "./types";
11
12export interface RunnerEnv {
13  LOADER: WorkerLoader;
14  ARTIFACTS: { get(name: string): Promise<ForkRepository> };
15}
16
17declare global {
18  namespace Cloudflare {
19    interface GlobalProps {
20      mainModule: typeof import("./index");
21      durableNamespaces: "ReceiptRunner";
22    }
23  }
24}
25
26/** Loopback capability is scoped to one authorized fork by the controlling Durable Object. */
27export class GitReadGateway extends WorkerEntrypoint<RunnerEnv, { remote: string }> {
28  override fetch(request: Request): Promise<Response> {
29    return forwardGitRead(request, this.ctx.props.remote);
30  }
31}
32
33/** Sandbox 1.0 native adapter. Export only through trusted bindings; there is no public fetch route. */
34export class ReceiptRunner extends DurableObject<RunnerEnv> {
35  private busy = false;
36  #snapshots = new Map<string, Snapshot>();
37
38  protected quickPorts(): QuickPorts {
39    return { loader: this.env.LOADER, evidence: "dynamic-worker", files: snapshot => {
40      const snapshotId = crypto.randomUUID();
41      this.#snapshots.set(snapshotId, snapshot);
42      return this.ctx.exports.QuickFiles({ props: { runnerId: this.ctx.id.toString(), snapshotId } });
43    } };
44  }
45  async readSnapshot(id: string, path: string): Promise<string | null> {
46    const snapshot = this.#snapshots.get(id);
47    if (!snapshot) throw new Error("Snapshot unavailable.");
48    return snapshot.read(path);
49  }
50  async listSnapshot(id: string, prefix: string): Promise<string[]> {
51    const snapshot = this.#snapshots.get(id);
52    if (!snapshot) throw new Error("Snapshot unavailable.");
53    return snapshot.list(prefix);
54  }
55  async statSnapshot(id: string, path: string): Promise<QuickFileStat | null> {
56    const snapshot = this.#snapshots.get(id);
57    if (!snapshot) throw new Error("Snapshot unavailable.");
58    return snapshot.stat(path);
59  }
60
61  constructor(ctx: DurableObjectState, env: RunnerEnv) {
62    super(ctx, env);
63    // A restarted DO must not inherit untrusted processes/files from its old execution.
64    ctx.blockConcurrencyWhile(async () => {
65      if (ctx.container?.running) await ctx.container.destroy();
66    });
67  }
68
69  protected containerPort(): ContainerPort | undefined { return this.ctx.container; }
70  protected evidence(): Receipt["evidence"] { return "container"; }
71
72  async run(request: RunRequest, authority: ReviewAuthority & RpcTarget): Promise<StoredReceipt> {
73    if (this.busy) throw new Error("Runner already in use");
74    const container = this.containerPort();
75    this.busy = true;
76    try {
77      let authorizedFork: string | undefined;
78      return await runReviewChecks(request, {
79        authority: {
80          authorize: async (input) => {
81            const review = await authority.authorize(input);
82            authorizedFork = review.forkRepoName;
83            return review;
84          },
85          storeReceipt: (record) => authority.storeReceipt(record),
86          setReviewReceiptDigest: (review, digest) => authority.setReviewReceiptDigest(review, digest),
87        },
88        getFork: (name) => {
89          if (!authorizedFork || name !== authorizedFork) throw new Error("Unauthorized fork");
90          return this.env.ARTIFACTS.get(name);
91        }, container,
92        gitGateway: (remote) => this.ctx.exports.GitReadGateway({ props: { remote } }),
93        evidence: this.evidence(),
94        quick: this.quickPorts(),
95      });
96    } finally {
97      for (const snapshot of this.#snapshots.values()) snapshot.close();
98      this.#snapshots.clear();
99      this.busy = false;
100    }
101  }
102}
103