@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 +2 -0
- package/lib/git-identity.ts +98 -0
- package/lib/orchestrator.ts +8 -1
- package/package.json +1 -1
- package/scripts/agent/task-down.sh +16 -10
- package/scripts/agent/task-up.sh +29 -6
- package/src/index.ts +8 -0
- package/src/protocol.ts +2 -0
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
|
|
package/lib/git-identity.ts
CHANGED
|
@@ -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
|
+
}
|
package/lib/orchestrator.ts
CHANGED
|
@@ -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
|
@@ -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
|
-
|
|
24
|
-
#
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
package/scripts/agent/task-up.sh
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|