@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.
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";
@@ -67,6 +74,7 @@ export {
67
74
  factoryFor as agentFactoryFor,
68
75
  list as listAgentAdapters,
69
76
  register as registerAgentAdapter,
77
+ supportsExecutionProfile,
70
78
  } from "../lib/agents/registry";
71
79
  export type {
72
80
  AgentKindCapability,
@@ -218,7 +226,37 @@ export const hostCommands: HostCommands = {
218
226
  {},
219
227
  );
220
228
  }
221
- 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
+ }
222
260
  });
223
261
  if (result.ok) {
224
262
  orchestrator.allowChannel(input.task.id);
@@ -236,10 +274,19 @@ export const hostCommands: HostCommands = {
236
274
  // background — it must never block or fail task-up. Awaiting it here would
237
275
  // couple the command result to a network token-exchange: a slow/hung
238
276
  // exchange (e.g. cloud mid-deploy) would trip the cloud's command timeout
239
- // and mark a running task as errored. setupTaskGithub injects + schedules
277
+ // and mark a running task as errored. The reconciler injects + schedules
240
278
  // (or emits a system note on failure) on its own; the agents come up
241
279
  // regardless and the token lands well before the first `gh` call.
242
- 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
+ );
243
290
  } else {
244
291
  recordTaskError(input.task.id);
245
292
  }
@@ -482,10 +529,10 @@ export const hostCommands: HostCommands = {
482
529
  }
483
530
  },
484
531
 
485
- async appendTranscript(_ctx, taskId, author, text) {
532
+ async appendTranscript(_ctx, taskId, author, text, targets) {
486
533
  // Per-message + high-frequency, so no logCommand (avoid log spam).
487
534
  try {
488
- writeTranscript(taskId, author, text);
535
+ writeTranscript(taskId, author, text, targets);
489
536
  return ok(undefined);
490
537
  } catch (err) {
491
538
  return failFromUnknown(err);
@@ -553,6 +600,14 @@ function mapAgentError(code: string): HostErrorCode {
553
600
  return HostErrorCode.WorktreeFailed;
554
601
  case "CLONE_FAILED":
555
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;
556
611
  case "FETCH_FAILED":
557
612
  return HostErrorCode.FetchFailed;
558
613
  case "RENDER_FAILED":
@@ -566,6 +621,21 @@ function mapAgentError(code: string): HostErrorCode {
566
621
  }
567
622
  }
568
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
+
569
639
  function mapDeliverError(message: string): HostErrorCode {
570
640
  if (message.includes("not found")) return HostErrorCode.TaskNotFound;
571
641
  if (message.includes("not running")) return HostErrorCode.TaskNotRunning;
@@ -574,7 +644,11 @@ function mapDeliverError(message: string): HostErrorCode {
574
644
  }
575
645
 
576
646
  function isRetryableAgentError(code: string): boolean {
577
- 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
+ );
578
652
  }
579
653
 
580
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";
@@ -72,6 +78,7 @@ import { ensureStandardImage, standardRuntimes } from "../lib/standard-image";
72
78
  import { hostCommands, hostEvents } from "./index";
73
79
  import {
74
80
  HostErrorCode,
81
+ TRANSCRIPT_TARGETS_PROTOCOL_FEATURE,
75
82
  type CloudToHost,
76
83
  type McpOp,
77
84
  type CommandContext,
@@ -81,6 +88,8 @@ import {
81
88
  type HostCommands,
82
89
  type HostToCloud,
83
90
  type PermissionDecision,
91
+ parseChannelMode,
92
+ parseTranscriptTargets,
84
93
  type TaskAgent,
85
94
  type TaskCommandProject,
86
95
  type TaskCommandTask,
@@ -212,6 +221,7 @@ async function startLocalUi(): Promise<void> {
212
221
  function buildCapabilities(): HostCapabilities {
213
222
  return {
214
223
  version: packageVersion(),
224
+ protocolFeatures: [TRANSCRIPT_TARGETS_PROTOCOL_FEATURE],
215
225
  agentKinds: agentKindCapabilities(),
216
226
  runtimes: standardRuntimes(),
217
227
  githubUsers: connectedUserIds(),
@@ -331,45 +341,108 @@ function connect(): void {
331
341
  closeTunnel(socket, frame.tunnelId, frame.reason);
332
342
  break;
333
343
  case "gh.connect.set": {
334
- const result = onConnectSet(frame);
335
- send(
336
- socket,
337
- result.ok
338
- ? { kind: "gh.connect.ack", userId: frame.userId, ok: true }
339
- : {
340
- kind: "gh.connect.ack",
341
- userId: frame.userId,
342
- ok: false,
343
- error: result.error ?? "store failed",
344
- },
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
+ },
345
377
  );
346
378
  break;
347
379
  }
348
- case "gh.connect.clear":
349
- // Delete-then-revoke (ADR-033) is async + best-effort; ack immediately
350
- // so the UI isn't gated on the GitHub revoke round-trip. The .catch is
351
- // a belt over onConnectClear's own try/catch a fire-and-forget
352
- // rejection must never crash the host.
353
- void onConnectClear(frame.userId).catch((err) =>
354
- console.warn(
355
- `[github] connect.clear failed: ${err instanceof Error ? err.message : err}`,
356
- ),
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
+ },
357
410
  );
358
- send(socket, { kind: "gh.connect.ack", userId: frame.userId, ok: true });
359
411
  break;
412
+ }
360
413
  case "ssh.key.get":
361
414
  case "ssh.key.ensure":
362
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
+ }
363
441
  try {
364
- let publicKey: string | null;
365
- if (frame.kind === "ssh.key.delete") {
366
- deleteSshKey(frame.userId);
367
- publicKey = null;
368
- } else if (frame.kind === "ssh.key.ensure") {
369
- publicKey = ensureSshKeyForUser(frame.userId);
370
- } else {
371
- publicKey = getSshPublicKey(frame.userId);
372
- }
442
+ const publicKey =
443
+ frame.kind === "ssh.key.ensure"
444
+ ? ensureSshKeyForUser(frame.userId)
445
+ : getSshPublicKey(frame.userId);
373
446
  send(socket, { kind: "ssh.key.ack", userId: frame.userId, ok: true, publicKey });
374
447
  } catch (err) {
375
448
  send(socket, {
@@ -956,6 +1029,7 @@ function dispatchCommand(
956
1029
  expectString(args, 0),
957
1030
  expectString(args, 1),
958
1031
  expectString(args, 2),
1032
+ parseTranscriptTargets(args[3]),
959
1033
  );
960
1034
  case "previewEnsure":
961
1035
  return hostCommands.previewEnsure(
@@ -1402,6 +1476,13 @@ function expectChannelEnsureInput(
1402
1476
  if (typeof input.globalContext === "string") {
1403
1477
  out.globalContext = input.globalContext;
1404
1478
  }
1479
+ // ADR-083: secretary identity drives the role-specific transcript preamble.
1480
+ // Both are optional for wire compatibility with older clouds.
1481
+ const mode = parseChannelMode(input.mode);
1482
+ if (mode !== undefined) out.mode = mode;
1483
+ if (typeof input.secretaryAgentId === "string") {
1484
+ out.secretaryAgentId = input.secretaryAgentId;
1485
+ }
1405
1486
  // ADR-053: browser testing flag (optional; tolerant of absence).
1406
1487
  if (input.browserTesting === true) out.browserTesting = true;
1407
1488
  // ADR-049: humans in the chat (optional; tolerant of absence for older
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",
@@ -27,6 +31,10 @@ export type HostCommandResult<T> =
27
31
  retryable?: boolean;
28
32
  };
29
33
 
34
+ /** Capability ids shared by host advertisement and fail-closed cloud gates. */
35
+ export const TRANSCRIPT_TARGETS_PROTOCOL_FEATURE = "transcript-targets-v1";
36
+ export const COMMUNICATOR_EXECUTION_PROFILE = "communicator";
37
+
30
38
  export interface CommandContext {
31
39
  commandId: string;
32
40
  }
@@ -70,6 +78,9 @@ export interface HostCapabilities {
70
78
  /** The host-agent package version — the cloud UI shows it on the host page
71
79
  * and flags when npm has a newer release. Optional (older hosts omit it). */
72
80
  version?: string;
81
+ /** Optional protocol features. Absence means an older host and fails closed
82
+ * for features whose fallback would weaken isolation (ADR-083). */
83
+ protocolFeatures?: string[];
73
84
  agentKinds: Array<{
74
85
  kind: string;
75
86
  label: string;
@@ -77,6 +88,13 @@ export interface HostCapabilities {
77
88
  defaultModel?: string;
78
89
  supportedEfforts: string[];
79
90
  defaultEffort?: string;
91
+ /** Execution profiles this adapter enforces, not prompt-only claims. */
92
+ executionProfiles?: Array<{
93
+ id: string;
94
+ mechanism: string;
95
+ defaultModel?: string;
96
+ defaultEffort?: string;
97
+ }>;
80
98
  }>;
81
99
  runtimes: Array<{
82
100
  kind: string;
@@ -88,6 +106,34 @@ export interface HostCapabilities {
88
106
  githubUsers?: string[];
89
107
  }
90
108
 
109
+ /** One transcript projection the cloud asks the host to append to (ADR-083). */
110
+ export type TranscriptTarget = "chat" | "chat-front";
111
+
112
+ /** Parse the optional channel mode without turning malformed-new-cloud input
113
+ * into legacy Open mode. Absence is the only backwards-compatible fallback. */
114
+ export function parseChannelMode(
115
+ value: unknown,
116
+ ): "open" | "secretary" | undefined {
117
+ if (value === undefined) return undefined;
118
+ if (value === "open" || value === "secretary") return value;
119
+ throw new Error("invalid command args: expected channel mode");
120
+ }
121
+
122
+ /** Parse the appendTranscript wire argument. `undefined` alone is the legacy
123
+ * three-argument command and maps to chat.md during a rolling deploy. */
124
+ export function parseTranscriptTargets(value: unknown): TranscriptTarget[] {
125
+ if (value === undefined) return ["chat"];
126
+ if (
127
+ !Array.isArray(value) ||
128
+ value.length === 0 ||
129
+ value.length > 2 ||
130
+ value.some((target) => target !== "chat" && target !== "chat-front")
131
+ ) {
132
+ throw new Error("invalid command args: expected transcript targets");
133
+ }
134
+ return [...new Set(value)];
135
+ }
136
+
91
137
  export interface TaskUpResult {
92
138
  composeProject: string;
93
139
  worktreePath: string;
@@ -200,6 +246,10 @@ export interface ChannelHuman {
200
246
  export interface ChannelEnsureInput {
201
247
  taskId: string;
202
248
  agents: TaskAgent[];
249
+ /** ADR-083: channel projection/routing mode. Optional for older clouds. */
250
+ mode?: "open" | "secretary";
251
+ /** The one human-facing communicator in secretary mode. */
252
+ secretaryAgentId?: string;
203
253
  /** ADR-049: the humans in the chat. Optional for wire back-compat; absent
204
254
  * or single-entry behaves exactly like the pre-ADR-049 single-human task. */
205
255
  humans?: ChannelHuman[];
@@ -382,6 +432,7 @@ export interface HostCommands {
382
432
  taskId: string,
383
433
  author: string,
384
434
  text: string,
435
+ targets: TranscriptTarget[],
385
436
  ): Promise<HostCommandResult<void>>;
386
437
  }
387
438