@runuai/host 0.8.21 → 0.8.23
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/agents/cursor.ts +25 -3
- package/lib/git-identity.ts +98 -0
- package/lib/orchestrator.ts +8 -1
- package/package.json +1 -1
- package/scripts/agent/task-up.sh +12 -5
package/lib/agents/cursor.ts
CHANGED
|
@@ -244,9 +244,31 @@ export class CursorSession implements AgentSession {
|
|
|
244
244
|
const m = mapCursorLine(line);
|
|
245
245
|
if (m.sessionId) this.sessionId = m.sessionId;
|
|
246
246
|
if (typeof m.textDelta === "string") {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
247
|
+
// Cursor (--stream-partial-output) streams incremental token deltas
|
|
248
|
+
// and then a REDUNDANT full-message snapshot. The timestamp_ms
|
|
249
|
+
// heuristic in mapCursorLine drops that final snapshot for models
|
|
250
|
+
// whose final line is untagged — but Cursor's multi-model support
|
|
251
|
+
// (ADR-068) means SOME models tag the final full snapshot with
|
|
252
|
+
// timestamp_ms too, so it slips through and gets appended on top of
|
|
253
|
+
// the tokens: every message doubled (live 2026-07-21). Reconcile
|
|
254
|
+
// against what we've already streamed instead of trusting the tag —
|
|
255
|
+
// robust to incremental tokens, cumulative snapshots, and the
|
|
256
|
+
// redundant final repeat alike.
|
|
257
|
+
const t = m.textDelta;
|
|
258
|
+
let delta: string | null;
|
|
259
|
+
if (t === acc) {
|
|
260
|
+
delta = null; // full-message snapshot we've already emitted
|
|
261
|
+
} else if (acc.length > 0 && t.startsWith(acc)) {
|
|
262
|
+
delta = t.slice(acc.length); // cumulative snapshot → growth only
|
|
263
|
+
acc = t;
|
|
264
|
+
} else {
|
|
265
|
+
delta = t; // incremental token / genuinely new text
|
|
266
|
+
acc += t;
|
|
267
|
+
}
|
|
268
|
+
if (delta) {
|
|
269
|
+
sawText = true;
|
|
270
|
+
this.emit({ type: "message_delta", text: delta });
|
|
271
|
+
}
|
|
250
272
|
}
|
|
251
273
|
if (m.toolCall) {
|
|
252
274
|
this.emit({
|
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
package/scripts/agent/task-up.sh
CHANGED
|
@@ -532,17 +532,24 @@ 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
|