@runuai/host 0.8.20 → 0.8.22

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/lib/agent.ts CHANGED
@@ -75,6 +75,8 @@ const TaskUpData = z.object({
75
75
  worktreePath: z.string(),
76
76
  codeServerPort: z.number().int().positive().optional(),
77
77
  previewPorts: PreviewPortRuntimesSchema.optional(),
78
+ /** Degraded start: uai-init failed twice; surfaced as a feed system note. */
79
+ initWarning: z.string().optional(),
78
80
  });
79
81
  export type TaskUpResult = z.infer<typeof TaskUpData>;
80
82
 
@@ -3,9 +3,20 @@
3
3
  * are authored as the user who created the task, not the image's generic
4
4
  * identity. Best-effort, mirrors the SSH setup: a failure logs but never aborts
5
5
  * task-up. Name falls back to the email when no display name is known.
6
+ *
7
+ * ensureTaskSshIdentity re-asserts the SSH push identity + its DEPENDENT git
8
+ * config the same way gh tokens are re-asserted at channel ensure — the
9
+ * one-shot copy at task-up proved fragile (live 2026-07-21: a recreated
10
+ * container lost the key silently; a later repair restored the key WITHOUT
11
+ * the config and pushes stayed on HTTPS → 403). Key and config are a unit.
6
12
  */
7
13
 
14
+ import { existsSync } from "node:fs";
15
+ import { resolve } from "node:path";
16
+
8
17
  import { dockerCli } from "./docker-exec";
18
+ import { env } from "./env";
19
+ import { removeTaskIdentity, writeTaskIdentity } from "./ssh";
9
20
 
10
21
  const EXEC_TIMEOUT_MS = 10_000;
11
22
 
@@ -60,3 +71,90 @@ export async function setupTaskGitIdentity(
60
71
  console.log(`[git] task ${taskId}: git identity → ${displayName} <${email}>`);
61
72
  return true;
62
73
  }
74
+
75
+ // The key's dependent git config, applied as one unit with the key itself:
76
+ // SSH routing for every GitHub remote (mirrors are born with the project's
77
+ // clone URL, usually https) + SSH commit/tag signing. Same statements as
78
+ // uai-init's key-gated block — which silently self-skips when the key is
79
+ // missing at ITS run time, hence re-applying here.
80
+ const SSH_GIT_CONFIG =
81
+ 'git config --global url."git@github.com:".insteadOf "https://github.com/"' +
82
+ ' && git config --global core.sshCommand "ssh -o StrictHostKeyChecking=accept-new"' +
83
+ " && git config --global gpg.format ssh" +
84
+ " && git config --global user.signingkey /home/node/.ssh/id_ed25519.pub" +
85
+ " && git config --global commit.gpgsign true" +
86
+ " && git config --global tag.gpgsign true";
87
+
88
+ /**
89
+ * Copy the SSH identity at `keyPath` into the task container and apply its
90
+ * dependent git config. Every failure is LOGGED with its step — the silent
91
+ * `|| true` chain this replaces let a missing key masquerade as a GitHub
92
+ * permissions problem for an entire afternoon.
93
+ */
94
+ export async function injectSshIdentity(
95
+ taskId: string,
96
+ keyPath: string,
97
+ exec: DockerExec = defaultExec,
98
+ ): Promise<boolean> {
99
+ const container = `task-${taskId}-app-1`;
100
+ const steps: ReadonlyArray<readonly [string, string[]]> = [
101
+ ["mkdir ~/.ssh", ["exec", "-u", "root", container, "mkdir", "-p", "/home/node/.ssh"]],
102
+ ["copy key", ["cp", keyPath, `${container}:/home/node/.ssh/id_ed25519`]],
103
+ ["copy pubkey", ["cp", `${keyPath}.pub`, `${container}:/home/node/.ssh/id_ed25519.pub`]],
104
+ [
105
+ "own + chmod",
106
+ [
107
+ "exec", "-u", "root", container, "sh", "-c",
108
+ "chown -R node:node /home/node/.ssh && chmod 700 /home/node/.ssh && chmod 600 /home/node/.ssh/id_ed25519",
109
+ ],
110
+ ],
111
+ ["ssh routing + signing config", ["exec", "-u", "node", container, "sh", "-c", SSH_GIT_CONFIG]],
112
+ ];
113
+ for (const [label, args] of steps) {
114
+ const res = await exec(args);
115
+ if (res.status !== 0) {
116
+ console.warn(
117
+ `[ssh] task ${taskId}: ${label} failed: ${res.stderr.trim() || `exit ${res.status}`}`,
118
+ );
119
+ return false;
120
+ }
121
+ }
122
+ console.log(`[ssh] task ${taskId}: push identity + git SSH config asserted`);
123
+ return true;
124
+ }
125
+
126
+ /**
127
+ * Materialize the task creator's SSH key (operator identity as fallback,
128
+ * mirroring task-up.sh), inject it + its git config into the container, and
129
+ * clean the on-disk private key back up. Safe to call on every channel
130
+ * ensure; a task with no identity anywhere logs once and pushes stay HTTPS.
131
+ */
132
+ export async function ensureTaskSshIdentity(
133
+ taskId: string,
134
+ ownerUserId: string | null | undefined,
135
+ exec: DockerExec = defaultExec,
136
+ ): Promise<boolean> {
137
+ let keyPath: string | null = null;
138
+ let perTask = false;
139
+ const dir = writeTaskIdentity(taskId, ownerUserId);
140
+ if (dir) {
141
+ keyPath = resolve(dir, "id_ed25519");
142
+ perTask = true;
143
+ } else {
144
+ const operatorKey = resolve(env.dataDir, "identity", "id_ed25519");
145
+ if (existsSync(operatorKey)) keyPath = operatorKey;
146
+ }
147
+ if (!keyPath) {
148
+ console.log(
149
+ `[ssh] task ${taskId}: no SSH identity for owner or operator — git pushes stay on HTTPS`,
150
+ );
151
+ return false;
152
+ }
153
+ try {
154
+ return await injectSshIdentity(taskId, keyPath, exec);
155
+ } finally {
156
+ // Host hygiene (same as task-up): the materialized private key never
157
+ // outlives the injection.
158
+ if (perTask) removeTaskIdentity(taskId);
159
+ }
160
+ }
@@ -35,7 +35,7 @@ import {
35
35
  import { ACTIVE_STATUSES } from "./task-status";
36
36
  import { getHostTask, upsertHostTask } from "./runtime-state";
37
37
  import { clearRefresh, setupTaskGithub } from "./github-tokens";
38
- import { setupTaskGitIdentity } from "./git-identity";
38
+ import { ensureTaskSshIdentity, setupTaskGitIdentity } from "./git-identity";
39
39
  import { dockerCli } from "./docker-exec";
40
40
  import {
41
41
  containerSkillFile,
@@ -455,6 +455,11 @@ class Orchestrator {
455
455
  void setupTaskGithub(channel.taskId, task.ownerUserId);
456
456
  }
457
457
 
458
+ // SSH push identity + its git config, re-asserted like the gh token above
459
+ // (one-shot at task-up proved fragile — recreated containers lose the key
460
+ // silently; live 2026-07-21). Best-effort + non-blocking.
461
+ void ensureTaskSshIdentity(channel.taskId, task.ownerUserId);
462
+
458
463
  // ADR-047: install any package skills (native Claude Agent Skills) into the
459
464
  // container BEFORE spawning agents, so they're discoverable on the first
460
465
  // turn. Idempotent + best-effort (returns fast when there are none); a slow
@@ -1647,6 +1652,7 @@ async function recoverOneTask(
1647
1652
  if (task.ownerUserId) {
1648
1653
  void setupTaskGithub(task.taskId, task.ownerUserId);
1649
1654
  }
1655
+ void ensureTaskSshIdentity(task.taskId, task.ownerUserId);
1650
1656
  return true;
1651
1657
  }
1652
1658
 
@@ -1686,6 +1692,7 @@ async function recoverOneTask(
1686
1692
  if (task.ownerUserId) {
1687
1693
  void setupTaskGithub(task.taskId, task.ownerUserId);
1688
1694
  }
1695
+ void ensureTaskSshIdentity(task.taskId, task.ownerUserId);
1689
1696
  console.log(
1690
1697
  `[orchestrator] recovery: ${task.taskId} resumed (port ${port ?? "?"})`,
1691
1698
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.8.20",
3
+ "version": "0.8.22",
4
4
  "description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Diogo Perillo <diogo.perillo@gmail.com>",
@@ -20,17 +20,23 @@ UAI_TASK_ID="$task_id"
20
20
  setup_err_trap
21
21
 
22
22
  step "TASK_NOT_FOUND" "read task row"
23
- task_json=$(db_get_task "$task_id")
24
- # Idempotent: it's fine if the task row is gone emit success and return.
25
- if [ "$(jq 'length' <<<"$task_json")" -lt 1 ]; then
26
- emit_ok '{"alreadyGone":true}'
27
- exit 0
23
+ # Teardown must never depend on bookkeeping: a broken/missing command
24
+ # snapshot (seen live 2026-07-21 cleaning up after a failed task-up
25
+ # db_get_task errored, the trap reported TASK_NOT_FOUND, and the container
26
+ # survived) still tears the stack down by convention-derived names.
27
+ task_json=$(db_get_task "$task_id" 2>/dev/null) || task_json="[]"
28
+ row_count=$(jq 'length' <<<"$task_json" 2>/dev/null) || row_count=0
29
+ if [ "$row_count" -lt 1 ]; then
30
+ log "task row unavailable — tearing down by derived names"
31
+ worktree_path=""
32
+ compose_project="$(compose_project_for_task "$task_id")"
33
+ projects_json="[]"
34
+ else
35
+ worktree_path=$(jq -r '.[0].worktree_path // ""' <<<"$task_json")
36
+ compose_project=$(jq -r '.[0].compose_project // ""' <<<"$task_json")
37
+ projects_json=$(db_get_projects_for_task "$task_id" 2>/dev/null) \
38
+ || projects_json="[]"
28
39
  fi
29
-
30
- worktree_path=$(jq -r '.[0].worktree_path // ""' <<<"$task_json")
31
- compose_project=$(jq -r '.[0].compose_project // ""' <<<"$task_json")
32
-
33
- projects_json=$(db_get_projects_for_task "$task_id")
34
40
  projects_root="$UAI_WORKSPACE_ROOT/projects"
35
41
 
36
42
  # task_dir falls back to the derived path when the row's worktree_path is
@@ -532,20 +532,41 @@ docker exec -u root "$app_container" \
532
532
  # else the operator identity — see above) into the container, so the agent signs
533
533
  # + pushes with the key whose .pub the user registered on GitHub. ADR-027 drops
534
534
  # the HTTPS/GH_TOKEN path; uai-init routes every GitHub remote over SSH.
535
- # Best-effort: signing + SSH push just stay off if the identity is absent.
535
+ # Best-effort: signing + SSH push just stay off if the identity is absent
536
+ # but every failure is LOGGED (a silent miss here reads as a GitHub
537
+ # permissions problem hours later; live 2026-07-21). The channel-ensure path
538
+ # (ensureTaskSshIdentity) re-asserts all of this on reconnects.
536
539
  if [ -f "$uai_identity_key" ]; then
537
540
  docker exec -u root "$app_container" \
538
- mkdir -p /home/node/.ssh >/dev/null 2>&1 || true
541
+ mkdir -p /home/node/.ssh >/dev/null 2>&1 \
542
+ || log "warning: ssh setup: mkdir ~/.ssh failed in $app_container"
539
543
  docker cp "$uai_identity_key" \
540
- "$app_container":/home/node/.ssh/id_ed25519 >/dev/null 2>&1 || true
544
+ "$app_container":/home/node/.ssh/id_ed25519 >/dev/null 2>&1 \
545
+ || log "warning: ssh setup: key copy failed — pushes will fall back to HTTPS"
541
546
  docker cp "${uai_identity_key}.pub" \
542
- "$app_container":/home/node/.ssh/id_ed25519.pub >/dev/null 2>&1 || true
547
+ "$app_container":/home/node/.ssh/id_ed25519.pub >/dev/null 2>&1 \
548
+ || log "warning: ssh setup: pubkey copy failed (signing will be off)"
543
549
  docker exec -u root "$app_container" sh -c \
544
550
  'chown -R node:node /home/node/.ssh && chmod 700 /home/node/.ssh && chmod 600 /home/node/.ssh/id_ed25519' \
545
- >/dev/null 2>&1 || true
551
+ >/dev/null 2>&1 \
552
+ || log "warning: ssh setup: chown/chmod failed — key may be unreadable by node"
546
553
  fi
547
554
 
548
- docker exec "$app_container" /usr/local/bin/uai-init >/dev/null
555
+ # uai-init (workspace deps + code-server) is re-runnable, and its death must
556
+ # not error a task whose container is already up (graceful degradation): a
557
+ # SIGKILLed exec session (observed live 2026-07-21, exit 137 with no timeout
558
+ # anywhere on this path) used to fail the whole task over a step anyone can
559
+ # re-run. Retry once, then come up DEGRADED with a warning the host surfaces
560
+ # as a system note in the task feed.
561
+ init_warning=""
562
+ if ! docker exec "$app_container" /usr/local/bin/uai-init >/dev/null; then
563
+ log "warning: uai-init failed — retrying once"
564
+ sleep 2
565
+ if ! docker exec "$app_container" /usr/local/bin/uai-init >/dev/null; then
566
+ init_warning="Workspace init (deps + editor) failed twice — the task is up, but the editor and installed dependencies may be missing. Retry from a task terminal with: /usr/local/bin/uai-init"
567
+ log "warning: $init_warning"
568
+ fi
569
+ fi
549
570
 
550
571
  # -----------------------------------------------------------------------------
551
572
  # 7. Discover code-server + preview ports, mark running, unlock.
@@ -588,7 +609,9 @@ emit_ok "$(jq -nc \
588
609
  --arg cp "$compose_project" \
589
610
  --arg wp "$task_dir" \
590
611
  --arg csport "${code_server_port:-}" \
612
+ --arg iw "${init_warning}" \
591
613
  --argjson previewPorts "$preview_ports_runtime_json" \
592
614
  '{composeProject:$cp,worktreePath:$wp}
593
615
  + (if $csport == "" then {} else {codeServerPort:($csport|tonumber)} end)
616
+ + (if $iw == "" then {} else {initWarning:$iw} end)
594
617
  + {previewPorts:$previewPorts}')"
package/src/index.ts CHANGED
@@ -123,6 +123,14 @@ export const hostCommands: HostCommands = {
123
123
  if (result.ok) {
124
124
  recordTaskUpResult(input.task.id, result.value);
125
125
  recordHostEvent(input.task.id, "task.started");
126
+ // Degraded start (uai-init failed twice): the task is up, but say so
127
+ // in the feed — a silent half-start reads as a broken product.
128
+ if (result.value.initWarning) {
129
+ getOrchestrator().emitSystemNote(
130
+ input.task.id,
131
+ result.value.initWarning,
132
+ );
133
+ }
126
134
  // GitHub auth for the container is best-effort (ADR-027) and runs in the
127
135
  // background — it must never block or fail task-up. Awaiting it here would
128
136
  // couple the command result to a network token-exchange: a slow/hung
package/src/protocol.ts CHANGED
@@ -93,6 +93,8 @@ export interface TaskUpResult {
93
93
  worktreePath: string;
94
94
  codeServerPort?: number;
95
95
  previewPorts?: Array<{ name: string; hostPort: number }>;
96
+ /** Degraded start: uai-init failed twice; the task is up without deps/editor. */
97
+ initWarning?: string;
96
98
  }
97
99
 
98
100
  export interface TaskDownResult {