@runuai/host 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +91 -0
- package/bin/uai-host.mjs +14 -0
- package/db/migrations/0000_host_tasks.sql +12 -0
- package/db/migrations/0001_host_ui.sql +11 -0
- package/db/migrations/0002_host_github_tokens.sql +8 -0
- package/db/migrations/0003_host_ssh_keys.sql +8 -0
- package/db/migrations/0004_host_owner_name.sql +1 -0
- package/db/migrations/meta/_journal.json +41 -0
- package/db/schema.ts +82 -0
- package/images/standard/Dockerfile +232 -0
- package/images/standard/README.md +122 -0
- package/images/standard/container/code-server-settings.json +36 -0
- package/images/standard/container/uai-init +215 -0
- package/images/standard/tool-versions +2 -0
- package/lib/agent.ts +292 -0
- package/lib/agents/claude.ts +343 -0
- package/lib/agents/codex.ts +522 -0
- package/lib/agents/factory.ts +34 -0
- package/lib/agents/mock.ts +133 -0
- package/lib/agents/proc.ts +172 -0
- package/lib/agents/registry.ts +109 -0
- package/lib/agents/types.ts +133 -0
- package/lib/attachments.ts +46 -0
- package/lib/cloud-state.ts +56 -0
- package/lib/command-db.ts +278 -0
- package/lib/db.ts +68 -0
- package/lib/env.ts +140 -0
- package/lib/git-diff.ts +370 -0
- package/lib/git-identity.ts +65 -0
- package/lib/github-tokens.ts +321 -0
- package/lib/orchestrator.ts +975 -0
- package/lib/preview-ports.ts +85 -0
- package/lib/repo-clone.ts +127 -0
- package/lib/runtime-state.ts +120 -0
- package/lib/secrets.ts +71 -0
- package/lib/ssh.ts +186 -0
- package/lib/standard-image.ts +152 -0
- package/lib/task-diff.ts +113 -0
- package/lib/task-status.ts +46 -0
- package/lib/transcript.ts +30 -0
- package/lib/ulid.ts +7 -0
- package/package.json +85 -0
- package/scripts/agent/_common.sh +248 -0
- package/scripts/agent/task-down.sh +113 -0
- package/scripts/agent/task-status.sh +54 -0
- package/scripts/agent/task-up.sh +457 -0
- package/scripts/install/darwin.ts +167 -0
- package/scripts/install/linux.ts +115 -0
- package/scripts/install/types.ts +35 -0
- package/scripts/install/util.ts +39 -0
- package/scripts/install/win.ts +130 -0
- package/src/cli.ts +445 -0
- package/src/index.ts +375 -0
- package/src/load-env.ts +52 -0
- package/src/main.ts +1156 -0
- package/src/paths.ts +64 -0
- package/src/protocol.ts +413 -0
- package/src/ui/server.ts +343 -0
- package/src/ui/types.ts +78 -0
- package/ui/app.js +264 -0
- package/ui/index.html +55 -0
- package/ui/style.css +359 -0
- package/ui/uai-logo-black.svg +9 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side GitHub token manager (ADR-027).
|
|
3
|
+
*
|
|
4
|
+
* Owns the encrypted refresh token (`host_github_tokens`), the per-task access
|
|
5
|
+
* token mint + container injection, and the refresh schedule. Access tokens are
|
|
6
|
+
* never stored — they go straight into the container's gh config. The refresh
|
|
7
|
+
* exchange is a host→cloud HTTP POST to /api/github/oauth/exchange.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
|
|
12
|
+
import { and, eq, isNull } from "drizzle-orm";
|
|
13
|
+
|
|
14
|
+
import { getDb, schema } from "./db";
|
|
15
|
+
import { sealAesGcm, openAesGcm } from "./secrets";
|
|
16
|
+
import type { CloudToHost } from "../src/protocol";
|
|
17
|
+
|
|
18
|
+
const REFRESH_LEAD_MS = 5 * 60 * 1000; // refresh 5 min before expiry
|
|
19
|
+
const EXEC_TIMEOUT_MS = 10_000;
|
|
20
|
+
const EXCHANGE_TIMEOUT_MS = 15_000; // cap the cloud token-exchange round-trip
|
|
21
|
+
|
|
22
|
+
type GhConnectSet = Extract<CloudToHost, { kind: "gh.connect.set" }>;
|
|
23
|
+
|
|
24
|
+
// --- token store ------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/** Encrypt + persist the user's refresh token (gh.connect.set handler). */
|
|
27
|
+
export function onConnectSet(frame: GhConnectSet): { ok: boolean; error?: string } {
|
|
28
|
+
try {
|
|
29
|
+
const sealed = sealAesGcm(frame.refreshToken);
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
getDb()
|
|
32
|
+
.insert(schema.githubTokens)
|
|
33
|
+
.values({
|
|
34
|
+
userId: frame.userId,
|
|
35
|
+
installationId: frame.installationId,
|
|
36
|
+
refreshTokenCt: sealed.ct,
|
|
37
|
+
refreshTokenNonce: sealed.nonce,
|
|
38
|
+
refreshTokenExpiresAt: frame.refreshTokenExpiresAt ?? null,
|
|
39
|
+
updatedAt: now,
|
|
40
|
+
})
|
|
41
|
+
.onConflictDoUpdate({
|
|
42
|
+
target: schema.githubTokens.userId,
|
|
43
|
+
set: {
|
|
44
|
+
installationId: frame.installationId,
|
|
45
|
+
refreshTokenCt: sealed.ct,
|
|
46
|
+
refreshTokenNonce: sealed.nonce,
|
|
47
|
+
refreshTokenExpiresAt: frame.refreshTokenExpiresAt ?? null,
|
|
48
|
+
updatedAt: now,
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
.run();
|
|
52
|
+
return { ok: true };
|
|
53
|
+
} catch (err) {
|
|
54
|
+
return { ok: false, error: err instanceof Error ? err.message : "store failed" };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Delete a user's refresh token + clear their active-task refresh timers. */
|
|
59
|
+
export function onConnectClear(userId: string): void {
|
|
60
|
+
deleteToken(userId);
|
|
61
|
+
for (const taskId of activeTaskIdsForUser(userId)) {
|
|
62
|
+
clearRefresh(taskId);
|
|
63
|
+
authExpiredHandler?.(taskId, userId, "GitHub disconnected");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function deleteToken(userId: string): void {
|
|
68
|
+
getDb()
|
|
69
|
+
.delete(schema.githubTokens)
|
|
70
|
+
.where(eq(schema.githubTokens.userId, userId))
|
|
71
|
+
.run();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function hasToken(userId: string): boolean {
|
|
75
|
+
return (
|
|
76
|
+
getDb()
|
|
77
|
+
.select({ userId: schema.githubTokens.userId })
|
|
78
|
+
.from(schema.githubTokens)
|
|
79
|
+
.where(eq(schema.githubTokens.userId, userId))
|
|
80
|
+
.get() != null
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function activeTaskIdsForUser(userId: string): string[] {
|
|
85
|
+
return getDb()
|
|
86
|
+
.select({ taskId: schema.hostTasks.taskId })
|
|
87
|
+
.from(schema.hostTasks)
|
|
88
|
+
.where(
|
|
89
|
+
and(
|
|
90
|
+
eq(schema.hostTasks.ownerUserId, userId),
|
|
91
|
+
isNull(schema.hostTasks.endedAt),
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
.all()
|
|
95
|
+
.map((r) => r.taskId);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- access-token exchange --------------------------------------------------
|
|
99
|
+
|
|
100
|
+
function exchangeUrl(): string {
|
|
101
|
+
const cloud = process.env.UAI_CLOUD_URL ?? "ws://127.0.0.1:8789/host";
|
|
102
|
+
const u = new URL(cloud);
|
|
103
|
+
const proto =
|
|
104
|
+
u.protocol === "wss:" ? "https:" : u.protocol === "ws:" ? "http:" : u.protocol;
|
|
105
|
+
return `${proto}//${u.host}/api/github/oauth/exchange`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface ExchangeResult {
|
|
109
|
+
accessToken: string;
|
|
110
|
+
expiresAt: number;
|
|
111
|
+
refreshToken: string | null;
|
|
112
|
+
refreshTokenExpiresAt: number | null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Mint a fresh access token for the user via the cloud exchange. Persists a
|
|
117
|
+
* rotated refresh token if GitHub returned one. Returns null when the user has
|
|
118
|
+
* no token on this host. Throws on exchange failure.
|
|
119
|
+
*/
|
|
120
|
+
export async function requestAccessToken(
|
|
121
|
+
userId: string,
|
|
122
|
+
): Promise<{ accessToken: string; expiresAt: number } | null> {
|
|
123
|
+
const row = getDb()
|
|
124
|
+
.select()
|
|
125
|
+
.from(schema.githubTokens)
|
|
126
|
+
.where(eq(schema.githubTokens.userId, userId))
|
|
127
|
+
.get();
|
|
128
|
+
if (!row) return null;
|
|
129
|
+
|
|
130
|
+
const refreshToken = openAesGcm(
|
|
131
|
+
Buffer.from(row.refreshTokenCt as Uint8Array),
|
|
132
|
+
Buffer.from(row.refreshTokenNonce as Uint8Array),
|
|
133
|
+
);
|
|
134
|
+
const res = await fetch(exchangeUrl(), {
|
|
135
|
+
method: "POST",
|
|
136
|
+
headers: {
|
|
137
|
+
"content-type": "application/json",
|
|
138
|
+
"x-uai-host-token": process.env.UAI_HOST_TOKEN ?? "",
|
|
139
|
+
},
|
|
140
|
+
body: JSON.stringify({ refreshToken }),
|
|
141
|
+
signal: AbortSignal.timeout(EXCHANGE_TIMEOUT_MS),
|
|
142
|
+
});
|
|
143
|
+
if (!res.ok) {
|
|
144
|
+
throw new Error(`gh token exchange failed: HTTP ${res.status}`);
|
|
145
|
+
}
|
|
146
|
+
const data = (await res.json()) as ExchangeResult;
|
|
147
|
+
if (data.refreshToken) {
|
|
148
|
+
const sealed = sealAesGcm(data.refreshToken);
|
|
149
|
+
getDb()
|
|
150
|
+
.update(schema.githubTokens)
|
|
151
|
+
.set({
|
|
152
|
+
refreshTokenCt: sealed.ct,
|
|
153
|
+
refreshTokenNonce: sealed.nonce,
|
|
154
|
+
refreshTokenExpiresAt:
|
|
155
|
+
data.refreshTokenExpiresAt ?? row.refreshTokenExpiresAt,
|
|
156
|
+
updatedAt: Date.now(),
|
|
157
|
+
})
|
|
158
|
+
.where(eq(schema.githubTokens.userId, userId))
|
|
159
|
+
.run();
|
|
160
|
+
}
|
|
161
|
+
return { accessToken: data.accessToken, expiresAt: data.expiresAt };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// --- container injection ----------------------------------------------------
|
|
165
|
+
|
|
166
|
+
/** Injectable docker-exec seam (mocked in tests). */
|
|
167
|
+
export type DockerExec = (
|
|
168
|
+
args: string[],
|
|
169
|
+
input: string,
|
|
170
|
+
) => { status: number | null; stderr: string };
|
|
171
|
+
|
|
172
|
+
const defaultExec: DockerExec = (args, input) => {
|
|
173
|
+
const res = spawnSync("docker", args, {
|
|
174
|
+
input,
|
|
175
|
+
encoding: "utf8",
|
|
176
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
177
|
+
});
|
|
178
|
+
return { status: res.status, stderr: res.stderr ?? "" };
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/** Write the access token into the container's gh config via `gh auth login`. */
|
|
182
|
+
export function injectIntoContainer(
|
|
183
|
+
taskId: string,
|
|
184
|
+
accessToken: string,
|
|
185
|
+
exec: DockerExec = defaultExec,
|
|
186
|
+
): void {
|
|
187
|
+
const container = `task-${taskId}-app-1`;
|
|
188
|
+
const args = [
|
|
189
|
+
"exec",
|
|
190
|
+
"-i",
|
|
191
|
+
"-u",
|
|
192
|
+
"node",
|
|
193
|
+
container,
|
|
194
|
+
"gh",
|
|
195
|
+
"auth",
|
|
196
|
+
"login",
|
|
197
|
+
"--with-token",
|
|
198
|
+
"--hostname",
|
|
199
|
+
"github.com",
|
|
200
|
+
];
|
|
201
|
+
const res = exec(args, `${accessToken}\n`);
|
|
202
|
+
if (res.status !== 0) {
|
|
203
|
+
throw new Error(`gh auth login failed in ${container}: ${res.stderr.trim()}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// --- refresh schedule -------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
210
|
+
|
|
211
|
+
type AuthExpiredHandler = (taskId: string, userId: string, reason: string) => void;
|
|
212
|
+
let authExpiredHandler: AuthExpiredHandler | null = null;
|
|
213
|
+
|
|
214
|
+
/** Phase D wires this to the task-chat "auth expired" system message. */
|
|
215
|
+
export function setAuthExpiredHandler(fn: AuthExpiredHandler | null): void {
|
|
216
|
+
authExpiredHandler = fn;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function scheduleRefresh(
|
|
220
|
+
taskId: string,
|
|
221
|
+
userId: string,
|
|
222
|
+
expiresAt: number,
|
|
223
|
+
): void {
|
|
224
|
+
clearRefresh(taskId);
|
|
225
|
+
const delay = Math.max(0, expiresAt - Date.now() - REFRESH_LEAD_MS);
|
|
226
|
+
const timer = setTimeout(() => {
|
|
227
|
+
void runRefresh(taskId, userId);
|
|
228
|
+
}, delay);
|
|
229
|
+
timer.unref?.();
|
|
230
|
+
timers.set(taskId, timer);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function runRefresh(taskId: string, userId: string): Promise<void> {
|
|
234
|
+
try {
|
|
235
|
+
const tok = await requestAccessToken(userId);
|
|
236
|
+
if (!tok) {
|
|
237
|
+
authExpiredHandler?.(taskId, userId, "no GitHub token on host");
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
injectIntoContainer(taskId, tok.accessToken);
|
|
241
|
+
scheduleRefresh(taskId, userId, tok.expiresAt);
|
|
242
|
+
} catch (err) {
|
|
243
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
244
|
+
// A revoked / expired refresh token can't recover — drop it to force a
|
|
245
|
+
// clean re-grant through the OAuth flow.
|
|
246
|
+
if (/\b40[13]\b|revoked|invalid_grant|bad_refresh/i.test(reason)) {
|
|
247
|
+
deleteToken(userId);
|
|
248
|
+
}
|
|
249
|
+
authExpiredHandler?.(taskId, userId, reason);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function clearRefresh(taskId: string): void {
|
|
254
|
+
const t = timers.get(taskId);
|
|
255
|
+
if (t) {
|
|
256
|
+
clearTimeout(t);
|
|
257
|
+
timers.delete(taskId);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Injectable seams for {@link setupTaskGithub} (mocked in tests so the decision
|
|
263
|
+
* logic needs neither the host DB nor docker). All default to the real impls.
|
|
264
|
+
*/
|
|
265
|
+
export interface SetupTaskDeps {
|
|
266
|
+
hasToken?: (userId: string) => boolean;
|
|
267
|
+
requestAccessToken?: (
|
|
268
|
+
userId: string,
|
|
269
|
+
) => Promise<{ accessToken: string; expiresAt: number } | null>;
|
|
270
|
+
inject?: (taskId: string, token: string) => void;
|
|
271
|
+
schedule?: (taskId: string, userId: string, expiresAt: number) => void;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Set up GitHub auth for a task at task-up (ADR-027). Connected user → mint +
|
|
276
|
+
* inject an access token + schedule refresh. Else UAI_GH_PAT_FALLBACK → inject
|
|
277
|
+
* the PAT (no refresh; attributes to the PAT owner). Else skip. Best-effort:
|
|
278
|
+
* a failure emits the auth-expired note but never aborts task-up. Returns true
|
|
279
|
+
* iff a token was injected.
|
|
280
|
+
*/
|
|
281
|
+
export async function setupTaskGithub(
|
|
282
|
+
taskId: string,
|
|
283
|
+
userId: string,
|
|
284
|
+
deps: SetupTaskDeps = {},
|
|
285
|
+
): Promise<boolean> {
|
|
286
|
+
const _hasToken = deps.hasToken ?? hasToken;
|
|
287
|
+
const _request = deps.requestAccessToken ?? requestAccessToken;
|
|
288
|
+
const _inject =
|
|
289
|
+
deps.inject ?? ((t: string, tok: string) => injectIntoContainer(t, tok));
|
|
290
|
+
const _schedule = deps.schedule ?? scheduleRefresh;
|
|
291
|
+
try {
|
|
292
|
+
if (_hasToken(userId)) {
|
|
293
|
+
const tok = await _request(userId);
|
|
294
|
+
if (tok) {
|
|
295
|
+
_inject(taskId, tok.accessToken);
|
|
296
|
+
_schedule(taskId, userId, tok.expiresAt);
|
|
297
|
+
console.log(`[github] task ${taskId}: injected user access token`);
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const pat = process.env.UAI_GH_PAT_FALLBACK;
|
|
302
|
+
if (pat) {
|
|
303
|
+
_inject(taskId, pat);
|
|
304
|
+
console.log(`[github] task ${taskId}: using PAT fallback (no refresh)`);
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
console.log(`[github] task ${taskId}: gh not configured for user ${userId}`);
|
|
308
|
+
return false;
|
|
309
|
+
} catch (err) {
|
|
310
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
311
|
+
console.warn(`[github] task ${taskId}: gh setup failed: ${reason}`);
|
|
312
|
+
authExpiredHandler?.(taskId, userId, reason);
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Test/maintenance hook: drop all scheduled refresh timers. */
|
|
318
|
+
export function clearAllRefreshTimers(): void {
|
|
319
|
+
for (const t of timers.values()) clearTimeout(t);
|
|
320
|
+
timers.clear();
|
|
321
|
+
}
|