flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/runner/src/credentials.ts

119 lines5,357 bytesCodeBlame
1/**
2 * Run credentials: the tokens a sandbox works with. Each is bound to its
3 * run, its repository and what that kind of run needs, acts as an agent on
4 * behalf of the person who started the work, and is revoked the moment
5 * the sandbox stops. See `crates/contracts/src/credentials.rs` for what
6 * each kind of run may do.
7 */
8
9import type { CreateRunCredentialInput, GitGrant, RepoPath, ServiceBinding } from "@g1t/contracts";
10
11/** The environment variables a sandbox's g1t tokens are passed in. */
12export const CREDENTIAL_VARS = ["G1T_TOKEN", "G1T_AGENT_TOKEN"] as const;
13
14const STORAGE_KEY = "credentials";
15
16/** How a run's model token starts (a model session, held by integrations). */
17export const MODEL_TOKEN_PREFIX = "g1tm_";
18const MODEL_STORAGE_KEY = "modelSessions";
19
20/** Identity's JSON protocol, as the contracts' client speaks it. */
21async function call<T>(identity: ServiceBinding, method: string, args: object): Promise<T> {
22 const response = await identity.fetch(`https://service/rpc/${method}`, {
23 method: "POST",
24 headers: { "content-type": "application/json" },
25 body: JSON.stringify(args),
26 });
27 if (!response.ok) throw new Error(`${method} failed with status ${response.status}`);
28 return (await response.json()) as T;
29}
30
31/** A run credential's text. */
32export async function runCredential(identity: ServiceBinding, input: CreateRunCredentialInput): Promise<string> {
33 const { token } = await call<{ token: string }>(identity, "create_run_credential", input);
34 return token;
35}
36
37/** The repository a `https://g1t.sh/<namespace>/<name>.git` remote names. */
38export function remotePath(url: string): RepoPath | null {
39 const match = /^https:\/\/[^/]+\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(url);
40 return match ? { namespace: match[1], name: match[2] } : null;
41}
42
43function samePath(a: RepoPath, b: RepoPath): boolean {
44 return a.namespace.toLowerCase() === b.namespace.toLowerCase() && a.name.toLowerCase() === b.name.toLowerCase();
45}
46
47/**
48 * Where a run working on a pull request may push: anywhere in the pull
49 * request's fork, which is its own; only its branch when the change is a
50 * branch of the repository itself.
51 */
52export function pushGrant(repo: RepoPath, source: RepoPath, branch: string | null | undefined): GitGrant {
53 return samePath(repo, source) ? { repo: source, branch: branch ?? null } : { repo: source, branch: null };
54}
55
56/** SHA-256 in lowercase hex, as identity stores tokens. */
57export async function sha256Hex(text: string): Promise<string> {
58 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
59 return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
60}
61
62/** The hashes of the g1t tokens among a sandbox's variables. */
63export async function credentialHashes(envVars: Record<string, string>): Promise<string[]> {
64 const tokens = CREDENTIAL_VARS.map((name) => envVars[name]).filter(
65 (value): value is string => typeof value === "string" && value.startsWith("g1t_"),
66 );
67 return Promise.all(tokens.map(sha256Hex));
68}
69
70/** The hashes of the run's model tokens among a sandbox's variables, each once. */
71export async function modelTokenHashes(envVars: Record<string, string>): Promise<string[]> {
72 const tokens = new Set(Object.values(envVars).filter((value) => typeof value === "string" && value.startsWith(MODEL_TOKEN_PREFIX)));
73 return Promise.all([...tokens].map(sha256Hex));
74}
75
76type Storage = {
77 put(key: string, value: unknown): Promise<void>;
78 get<T>(key: string): Promise<T | undefined>;
79 delete(key: string): Promise<boolean>;
80};
81
82/**
83 * Remembers a sandbox's credentials, by hash, so they can be revoked when
84 * it stops, and ties them to the run it recorded. Never stops the sandbox
85 * from starting.
86 */
87export async function holdCredentials(
88 identity: ServiceBinding,
89 storage: Storage,
90 envVars: Record<string, string>,
91 runId: string | null,
92): Promise<void> {
93 const models = await modelTokenHashes(envVars);
94 if (models.length > 0) await storage.put(MODEL_STORAGE_KEY, models);
95 const hashes = await credentialHashes(envVars);
96 if (hashes.length === 0) return;
97 await storage.put(STORAGE_KEY, hashes);
98 if (!runId) return;
99 await call(identity, "bind_run_credentials", { tokenHashes: hashes, runId }).catch((error: unknown) => console.log("run credentials not bound", runId, String(error)));
100}
101
102/**
103 * Ends a sandbox's credentials, once: its g1t tokens, and, when
104 * `integrations` is given, its model tokens, which the model proxy then
105 * refuses within seconds rather than when they would lapse.
106 */
107export async function revokeCredentials(identity: ServiceBinding, storage: Storage, integrations?: ServiceBinding): Promise<void> {
108 const models = integrations ? await storage.get<string[]>(MODEL_STORAGE_KEY) : undefined;
109 if (integrations && models?.length) {
110 await storage.delete(MODEL_STORAGE_KEY);
111 await call(integrations, "close_model_sessions", { token_hashes: models }).catch((error: unknown) =>
112 console.log("model sessions not closed", String(error)),
113 );
114 }
115 const hashes = await storage.get<string[]>(STORAGE_KEY);
116 if (!hashes?.length) return;
117 await storage.delete(STORAGE_KEY);
118 await call(identity, "revoke_run_credentials", { tokenHashes: hashes }).catch((error: unknown) => console.log("run credentials not revoked", String(error)));
119}