@runuai/host 0.9.1 → 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.
package/src/index.ts CHANGED
@@ -4,7 +4,14 @@ import { cloneRepo } from "../lib/repo-clone";
4
4
  import { handleFilesOp } from "../lib/shared-files";
5
5
  import { getOrchestrator } from "../lib/orchestrator";
6
6
  import { storeTaskCliSecret } from "../lib/agent-cli";
7
- import { setupTaskGithub, clearRefresh } from "../lib/github-tokens";
7
+ import {
8
+ clearRefresh,
9
+ reconcileTaskGitAuth,
10
+ } from "../lib/github-tokens";
11
+ import {
12
+ prepareTaskGithubGitCredential,
13
+ type TaskGithubGitCredential,
14
+ } from "../lib/github-git-auth";
8
15
  import { readAttachment, writeAttachment } from "../lib/attachments";
9
16
  import { appendTranscript as writeTranscript } from "../lib/transcript";
10
17
  import { buildTaskDiff } from "../lib/task-diff";
@@ -219,7 +226,37 @@ export const hostCommands: HostCommands = {
219
226
  {},
220
227
  );
221
228
  }
222
- return agent.taskUp(input);
229
+ let gitCredential: TaskGithubGitCredential | null;
230
+ try {
231
+ gitCredential = input.projects.some((project) =>
232
+ isGithubRepo(project.repoUrl),
233
+ )
234
+ ? await prepareTaskGithubGitCredential(input.task.ownerUserId)
235
+ : null;
236
+ } catch {
237
+ throw new AgentError(
238
+ "GITHUB_AUTH_UNAVAILABLE",
239
+ "GitHub is connected on this host, but Uai could not prepare its credential for Git. Retry the task; if it persists, reconnect GitHub on this host.",
240
+ );
241
+ }
242
+ try {
243
+ return gitCredential
244
+ ? await gitCredential.run(() =>
245
+ agent.taskUp(input, {
246
+ githubCredentialSocket: gitCredential.socketPath,
247
+ }),
248
+ )
249
+ : await agent.taskUp(input);
250
+ } finally {
251
+ await gitCredential?.close().catch((error: unknown) => {
252
+ // Cleanup must not replace the actual task-up result/error. The
253
+ // credential is short-lived and close() also removes its private
254
+ // directory in a finally block; retain diagnostics for operators.
255
+ console.warn(
256
+ `[github] task ${input.task.id}: credential cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
257
+ );
258
+ });
259
+ }
223
260
  });
224
261
  if (result.ok) {
225
262
  orchestrator.allowChannel(input.task.id);
@@ -237,10 +274,19 @@ export const hostCommands: HostCommands = {
237
274
  // background — it must never block or fail task-up. Awaiting it here would
238
275
  // couple the command result to a network token-exchange: a slow/hung
239
276
  // exchange (e.g. cloud mid-deploy) would trip the cloud's command timeout
240
- // and mark a running task as errored. setupTaskGithub injects + schedules
277
+ // and mark a running task as errored. The reconciler injects + schedules
241
278
  // (or emits a system note on failure) on its own; the agents come up
242
279
  // regardless and the token lands well before the first `gh` call.
243
- void setupTaskGithub(input.task.id, input.task.ownerUserId);
280
+ void reconcileTaskGitAuth(
281
+ input.task.id,
282
+ input.task.ownerUserId,
283
+ ).catch((err) =>
284
+ console.warn(
285
+ `[github] task ${input.task.id}: post-start reconciliation failed: ${
286
+ err instanceof Error ? err.message : String(err)
287
+ }`,
288
+ ),
289
+ );
244
290
  } else {
245
291
  recordTaskError(input.task.id);
246
292
  }
@@ -554,6 +600,14 @@ function mapAgentError(code: string): HostErrorCode {
554
600
  return HostErrorCode.WorktreeFailed;
555
601
  case "CLONE_FAILED":
556
602
  return HostErrorCode.CloneFailed;
603
+ case "GITHUB_SSH_ACCESS_DENIED":
604
+ return HostErrorCode.GitHubSshAccessDenied;
605
+ case "GITHUB_TOKEN_ACCESS_DENIED":
606
+ return HostErrorCode.GitHubTokenAccessDenied;
607
+ case "GITHUB_AUTH_UNAVAILABLE":
608
+ return HostErrorCode.GitHubAuthUnavailable;
609
+ case "GITHUB_CONNECTION_REQUIRED":
610
+ return HostErrorCode.GitHubConnectionRequired;
557
611
  case "FETCH_FAILED":
558
612
  return HostErrorCode.FetchFailed;
559
613
  case "RENDER_FAILED":
@@ -567,6 +621,21 @@ function mapAgentError(code: string): HostErrorCode {
567
621
  }
568
622
  }
569
623
 
624
+ /** Host-side predicate only; API-boundary normalization already canonicalizes
625
+ * normal GitHub projects. Keep scratchpads/GitLab/local remotes independent of
626
+ * an unrelated broken GitHub connection on the same host. */
627
+ function isGithubRepo(input: string): boolean {
628
+ const value = input.trim();
629
+ if (/^(?:[^@/]+@)?(?:www\.)?github\.com:/i.test(value)) return true;
630
+ if (/^[\w.-]+\/[\w.-]+(?:\.git)?\/?$/i.test(value)) return true;
631
+ try {
632
+ const url = new URL(value);
633
+ return /^(?:www\.)?github\.com$/i.test(url.hostname);
634
+ } catch {
635
+ return false;
636
+ }
637
+ }
638
+
570
639
  function mapDeliverError(message: string): HostErrorCode {
571
640
  if (message.includes("not found")) return HostErrorCode.TaskNotFound;
572
641
  if (message.includes("not running")) return HostErrorCode.TaskNotRunning;
@@ -575,7 +644,11 @@ function mapDeliverError(message: string): HostErrorCode {
575
644
  }
576
645
 
577
646
  function isRetryableAgentError(code: string): boolean {
578
- return code === "FETCH_FAILED" || code === "CONTAINER_INIT_FAILED";
647
+ return (
648
+ code === "FETCH_FAILED" ||
649
+ code === "CONTAINER_INIT_FAILED" ||
650
+ code === "GITHUB_AUTH_UNAVAILABLE"
651
+ );
579
652
  }
580
653
 
581
654
  function toLegacyDecision(decision: PermissionDecision): "accept" | "decline" {
package/src/main.ts CHANGED
@@ -30,11 +30,17 @@ import {
30
30
  onConnectClear,
31
31
  onConnectSet,
32
32
  onGithubChange,
33
+ reconcileTaskGitAuth,
34
+ runGithubConnectionTransition,
33
35
  setAuthExpiredHandler,
34
36
  } from "../lib/github-tokens";
37
+ import { invalidateTaskGithubGitCredentials } from "../lib/github-git-auth";
38
+ import {
39
+ deleteUserSshIdentity,
40
+ removeTaskSshIdentityFromContainer,
41
+ } from "../lib/git-identity";
35
42
  import { reinjectCodexRunningTasks, watchCodexAuth } from "../lib/codex-auth";
36
43
  import {
37
- deleteKey as deleteSshKey,
38
44
  ensureKeyForUser as ensureSshKeyForUser,
39
45
  getPublicKey as getSshPublicKey,
40
46
  } from "../lib/ssh";
@@ -335,45 +341,108 @@ function connect(): void {
335
341
  closeTunnel(socket, frame.tunnelId, frame.reason);
336
342
  break;
337
343
  case "gh.connect.set": {
338
- const result = onConnectSet(frame);
339
- send(
340
- socket,
341
- result.ok
342
- ? { kind: "gh.connect.ack", userId: frame.userId, ok: true }
343
- : {
344
- kind: "gh.connect.ack",
345
- userId: frame.userId,
346
- ok: false,
347
- error: result.error ?? "store failed",
348
- },
344
+ // Account switches fence every host-side Git operation using the old
345
+ // credential before the replacement grant is stored or reinjected.
346
+ void runGithubConnectionTransition(frame.userId, () =>
347
+ onConnectSet(frame, {
348
+ invalidateCredentials: invalidateTaskGithubGitCredentials,
349
+ reconcile: (taskId, userId) =>
350
+ getOrchestrator().runTaskLifecycle(taskId, () =>
351
+ reconcileTaskGitAuth(taskId, userId),
352
+ ),
353
+ }),
354
+ ).then(
355
+ (result) =>
356
+ send(
357
+ socket,
358
+ result.ok
359
+ ? { kind: "gh.connect.ack", userId: frame.userId, ok: true }
360
+ : {
361
+ kind: "gh.connect.ack",
362
+ userId: frame.userId,
363
+ ok: false,
364
+ error: result.error ?? "store failed",
365
+ },
366
+ ),
367
+ (err) => {
368
+ const error = err instanceof Error ? err.message : String(err);
369
+ console.warn(`[github] connect.set failed: ${error}`);
370
+ send(socket, {
371
+ kind: "gh.connect.ack",
372
+ userId: frame.userId,
373
+ ok: false,
374
+ error,
375
+ });
376
+ },
349
377
  );
350
378
  break;
351
379
  }
352
- case "gh.connect.clear":
353
- // Delete-then-revoke (ADR-033) is async + best-effort; ack immediately
354
- // so the UI isn't gated on the GitHub revoke round-trip. The .catch is
355
- // a belt over onConnectClear's own try/catch a fire-and-forget
356
- // rejection must never crash the host.
357
- void onConnectClear(frame.userId).catch((err) =>
358
- console.warn(
359
- `[github] connect.clear failed: ${err instanceof Error ? err.message : err}`,
360
- ),
380
+ case "gh.connect.clear": {
381
+ // Local deletion happens synchronously inside onConnectClear and the
382
+ // remote revoke starts immediately in the background. Delay the ack
383
+ // only for live-container logout + transport transition, so the UI
384
+ // cannot report Disconnected while a task can still use the old token.
385
+ void runGithubConnectionTransition(frame.userId, () =>
386
+ onConnectClear(frame.userId, {
387
+ invalidateCredentials: invalidateTaskGithubGitCredentials,
388
+ reconcile: (taskId, userId) =>
389
+ getOrchestrator().runTaskLifecycle(taskId, () =>
390
+ reconcileTaskGitAuth(taskId, userId),
391
+ ),
392
+ }),
393
+ ).then(
394
+ () =>
395
+ send(socket, {
396
+ kind: "gh.connect.ack",
397
+ userId: frame.userId,
398
+ ok: true,
399
+ }),
400
+ (err) => {
401
+ const error = err instanceof Error ? err.message : String(err);
402
+ console.warn(`[github] connect.clear failed: ${error}`);
403
+ send(socket, {
404
+ kind: "gh.connect.ack",
405
+ userId: frame.userId,
406
+ ok: false,
407
+ error,
408
+ });
409
+ },
361
410
  );
362
- send(socket, { kind: "gh.connect.ack", userId: frame.userId, ok: true });
363
411
  break;
412
+ }
364
413
  case "ssh.key.get":
365
414
  case "ssh.key.ensure":
366
415
  case "ssh.key.delete": {
416
+ if (frame.kind === "ssh.key.delete") {
417
+ void deleteUserSshIdentity(frame.userId, {
418
+ removeFromContainer: (taskId) =>
419
+ getOrchestrator().runTaskLifecycle(taskId, () =>
420
+ removeTaskSshIdentityFromContainer(taskId),
421
+ ),
422
+ }).then(
423
+ () =>
424
+ send(socket, {
425
+ kind: "ssh.key.ack",
426
+ userId: frame.userId,
427
+ ok: true,
428
+ publicKey: null,
429
+ }),
430
+ (err) =>
431
+ send(socket, {
432
+ kind: "ssh.key.ack",
433
+ userId: frame.userId,
434
+ ok: false,
435
+ error:
436
+ err instanceof Error ? err.message : "ssh key delete failed",
437
+ }),
438
+ );
439
+ break;
440
+ }
367
441
  try {
368
- let publicKey: string | null;
369
- if (frame.kind === "ssh.key.delete") {
370
- deleteSshKey(frame.userId);
371
- publicKey = null;
372
- } else if (frame.kind === "ssh.key.ensure") {
373
- publicKey = ensureSshKeyForUser(frame.userId);
374
- } else {
375
- publicKey = getSshPublicKey(frame.userId);
376
- }
442
+ const publicKey =
443
+ frame.kind === "ssh.key.ensure"
444
+ ? ensureSshKeyForUser(frame.userId)
445
+ : getSshPublicKey(frame.userId);
377
446
  send(socket, { kind: "ssh.key.ack", userId: frame.userId, ok: true, publicKey });
378
447
  } catch (err) {
379
448
  send(socket, {
package/src/protocol.ts CHANGED
@@ -9,6 +9,10 @@ export enum HostErrorCode {
9
9
  ComposeDownFailed = "compose_down_failed",
10
10
  WorktreeFailed = "worktree_failed",
11
11
  CloneFailed = "clone_failed",
12
+ GitHubSshAccessDenied = "github_ssh_access_denied",
13
+ GitHubTokenAccessDenied = "github_token_access_denied",
14
+ GitHubAuthUnavailable = "github_auth_unavailable",
15
+ GitHubConnectionRequired = "github_connection_required",
12
16
  FetchFailed = "fetch_failed",
13
17
  RenderFailed = "render_failed",
14
18
  DbFailed = "db_failed",