@runuai/host 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Ephemeral host-side Git credential for a task's initial mirror fetch.
3
+ *
4
+ * Projects/mirrors are shared, but GitHub credentials are per user and host.
5
+ * A private credential-cache socket keeps those boundaries intact: the token
6
+ * is seeded over stdin, only the non-secret socket path reaches task-up.sh,
7
+ * and the daemon + socket directory are removed as soon as task-up finishes.
8
+ */
9
+
10
+ import { spawn } from "node:child_process";
11
+ import { chmodSync, mkdtempSync, rmSync } from "node:fs";
12
+ import { join } from "node:path";
13
+
14
+ import { requestAccessToken } from "./github-tokens";
15
+
16
+ // A derived-image build can legitimately keep task-up busy for well over 15
17
+ // minutes before the first authenticated fetch. Keep the daemon available for
18
+ // the bounded task-up lifetime; close() and user invalidation still terminate
19
+ // it immediately once that operation settles.
20
+ const CACHE_TIMEOUT_SECONDS = 6 * 60 * 60;
21
+ const GIT_TIMEOUT_MS = 10_000;
22
+
23
+ type GitRunner = (
24
+ args: string[],
25
+ input: string,
26
+ ) => Promise<{ status: number | null; stderr: string }>;
27
+
28
+ const runGit: GitRunner = (args, input) =>
29
+ new Promise((resolve, reject) => {
30
+ const child = spawn("git", args, {
31
+ stdio: ["pipe", "ignore", "pipe"],
32
+ env: process.env,
33
+ });
34
+ let stderr = "";
35
+ const timer = setTimeout(() => child.kill("SIGKILL"), GIT_TIMEOUT_MS);
36
+ child.stderr.on("data", (chunk) => {
37
+ // Bound diagnostics; credential input is on stdin and never appears here.
38
+ if (stderr.length < 8_192) stderr += chunk.toString("utf8");
39
+ });
40
+ child.on("error", (err) => {
41
+ clearTimeout(timer);
42
+ reject(err);
43
+ });
44
+ child.on("close", (status) => {
45
+ clearTimeout(timer);
46
+ resolve({ status, stderr });
47
+ });
48
+ // If git exits before consuming the credential (spawn failure, malformed
49
+ // helper, timeout), the pipe may close first. The child status/error is
50
+ // authoritative; swallow EPIPE so it cannot become an unhandled event.
51
+ child.stdin.on("error", () => {});
52
+ child.stdin.end(input);
53
+ });
54
+
55
+ export interface TaskGithubGitCredential {
56
+ /** Non-secret path consumed by task-up's isolated credential helper. */
57
+ socketPath: string;
58
+ /**
59
+ * Run the complete credential-using task-up operation under the user's
60
+ * disconnect/account-switch fence. A stale handle is rejected before the
61
+ * operation starts; invalidation waits for an already-started operation.
62
+ */
63
+ run<T>(operation: () => Promise<T>): Promise<T>;
64
+ /** Idempotently stop the daemon and remove its private directory. */
65
+ close(): Promise<void>;
66
+ }
67
+
68
+ interface PrepareDeps {
69
+ requestToken?: typeof requestAccessToken;
70
+ git?: GitRunner;
71
+ platform?: NodeJS.Platform;
72
+ }
73
+
74
+ const credentialGeneration = new Map<string, number>();
75
+ const activeCredentials = new Map<string, Set<TaskGithubGitCredential>>();
76
+ const activeCredentialOperations = new Map<string, Set<Promise<void>>>();
77
+
78
+ function currentGeneration(userId: string): number {
79
+ return credentialGeneration.get(userId) ?? 0;
80
+ }
81
+
82
+ /**
83
+ * Invalidate every in-flight/active host fetch credential for a user. Used by
84
+ * Disconnect so its acknowledgement also covers task-up processes that were
85
+ * between token exchange and container reconciliation.
86
+ */
87
+ export async function invalidateTaskGithubGitCredentials(
88
+ userId: string,
89
+ ): Promise<void> {
90
+ credentialGeneration.set(userId, currentGeneration(userId) + 1);
91
+ const operations = [...(activeCredentialOperations.get(userId) ?? [])];
92
+
93
+ // Let credential-using Git finish before stopping its helper daemon. The
94
+ // generation increment above prevents every old handle that has not started
95
+ // yet from entering run(), while operations already registered here drain.
96
+ await Promise.allSettled(operations);
97
+
98
+ const credentials = [...(activeCredentials.get(userId) ?? [])];
99
+ await Promise.allSettled(credentials.map((credential) => credential.close()));
100
+ }
101
+
102
+ /**
103
+ * Prepare the task owner's GitHub token for host-side HTTPS Git. Returns null
104
+ * when that user has not connected GitHub on this host, which selects the SSH
105
+ * fallback. Throws when a stored connection exists but cannot be prepared.
106
+ */
107
+ export async function prepareTaskGithubGitCredential(
108
+ userId: string,
109
+ deps: PrepareDeps = {},
110
+ ): Promise<TaskGithubGitCredential | null> {
111
+ const generation = currentGeneration(userId);
112
+ const token = await (deps.requestToken ?? requestAccessToken)(userId);
113
+ if (!token) return null;
114
+ if (generation !== currentGeneration(userId)) {
115
+ throw new Error("GitHub credential changed while preparing task Git auth");
116
+ }
117
+ if ((deps.platform ?? process.platform) === "win32") {
118
+ throw new Error(
119
+ "GitHub host credentials require Unix sockets; run the Uai host agent in WSL or another Unix environment",
120
+ );
121
+ }
122
+
123
+ // credential-cache invokes its helper string through a shell. Use a
124
+ // controlled, whitespace-free Unix root so the socket never needs to carry
125
+ // user/TMPDIR-controlled shell syntax. The host task runner itself requires
126
+ // a Unix-like environment (bash + Unix sockets).
127
+ const directory = mkdtempSync(join("/tmp", "uai-git-credential-"));
128
+ chmodSync(directory, 0o700);
129
+ const socketPath = join(directory, "socket");
130
+ const helper = `credential.helper=cache --timeout=${CACHE_TIMEOUT_SECONDS} --socket=${socketPath}`;
131
+ const git = deps.git ?? runGit;
132
+ let closePromise: Promise<void> | null = null;
133
+
134
+ let taskCredential: TaskGithubGitCredential | null = null;
135
+ const close = async (): Promise<void> => {
136
+ if (!closePromise) {
137
+ closePromise = (async () => {
138
+ try {
139
+ await git(["credential-cache", `--socket=${socketPath}`, "exit"], "");
140
+ } finally {
141
+ rmSync(directory, { recursive: true, force: true });
142
+ if (taskCredential) {
143
+ const userCredentials = activeCredentials.get(userId);
144
+ userCredentials?.delete(taskCredential);
145
+ if (userCredentials?.size === 0) activeCredentials.delete(userId);
146
+ }
147
+ }
148
+ })();
149
+ }
150
+ await closePromise;
151
+ };
152
+
153
+ const run = async <T>(operation: () => Promise<T>): Promise<T> => {
154
+ if (generation !== currentGeneration(userId)) {
155
+ throw new Error("GitHub credential changed before task Git operation");
156
+ }
157
+ let finish!: () => void;
158
+ const fence = new Promise<void>((resolve) => {
159
+ finish = resolve;
160
+ });
161
+ const operations =
162
+ activeCredentialOperations.get(userId) ?? new Set<Promise<void>>();
163
+ operations.add(fence);
164
+ activeCredentialOperations.set(userId, operations);
165
+ try {
166
+ return await operation();
167
+ } finally {
168
+ finish();
169
+ operations.delete(fence);
170
+ if (operations.size === 0) activeCredentialOperations.delete(userId);
171
+ }
172
+ };
173
+
174
+ const credential = [
175
+ "protocol=https",
176
+ "host=github.com",
177
+ "username=x-access-token",
178
+ `password=${token.accessToken}`,
179
+ "",
180
+ "",
181
+ ].join("\n");
182
+
183
+ try {
184
+ const seeded = await git(
185
+ ["-c", "credential.helper=", "-c", helper, "credential", "approve"],
186
+ credential,
187
+ );
188
+ if (seeded.status !== 0) {
189
+ throw new Error(
190
+ `git credential cache setup failed: ${seeded.stderr.trim() || `exit ${seeded.status}`}`,
191
+ );
192
+ }
193
+ taskCredential = { socketPath, run, close };
194
+ if (generation !== currentGeneration(userId)) {
195
+ await close();
196
+ throw new Error("GitHub credential changed while preparing task Git auth");
197
+ }
198
+ const userCredentials =
199
+ activeCredentials.get(userId) ?? new Set<TaskGithubGitCredential>();
200
+ userCredentials.add(taskCredential);
201
+ activeCredentials.set(userId, userCredentials);
202
+ return taskCredential;
203
+ } catch (err) {
204
+ await close().catch(() => {});
205
+ throw err;
206
+ }
207
+ }