@runuai/host 0.8.22 → 0.8.24

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.
@@ -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
- sawText = true;
248
- acc += m.textDelta;
249
- this.emit({ type: "message_delta", text: m.textDelta });
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({
@@ -123,38 +123,86 @@ export async function injectSshIdentity(
123
123
  return true;
124
124
  }
125
125
 
126
+ // One in-flight ensure per task. Channel-ensure fires on every reconnect/
127
+ // nudge (seconds apart under load), and `docker cp` is unlink+create — two
128
+ // concurrent injects interleave so one's `cp id_ed25519` lands AFTER the
129
+ // other's chown, leaving the private key owned by the macOS uid and
130
+ // UNREADABLE by node inside the container ("Load key: Permission denied",
131
+ // live 2026-07-21). Serialize, and skip entirely when the key is already
132
+ // good, so the steady state does zero docker work.
133
+ const sshEnsureInFlight = new Set<string>();
134
+
135
+ /** True when node can already read a correctly-owned key in the container. */
136
+ async function sshKeyHealthy(
137
+ taskId: string,
138
+ exec: DockerExec,
139
+ ): Promise<boolean> {
140
+ const res = await exec([
141
+ "exec",
142
+ "-u",
143
+ "node",
144
+ `task-${taskId}-app-1`,
145
+ "test",
146
+ "-r",
147
+ "/home/node/.ssh/id_ed25519",
148
+ ]);
149
+ return res.status === 0;
150
+ }
151
+
126
152
  /**
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.
153
+ * Ensure the task container has a node-readable SSH push identity + git
154
+ * config. Idempotent and race-free: skips the copy when the key is already
155
+ * healthy (the common case), and never runs two injects for one task at
156
+ * once. Only (re)materializes + copies the key when it is missing or
157
+ * unreadable. Safe to call on every channel ensure.
131
158
  */
132
159
  export async function ensureTaskSshIdentity(
133
160
  taskId: string,
134
161
  ownerUserId: string | null | undefined,
135
162
  exec: DockerExec = defaultExec,
136
163
  ): 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
- }
164
+ if (sshEnsureInFlight.has(taskId)) return true; // another ensure owns it
165
+ sshEnsureInFlight.add(taskId);
153
166
  try {
154
- return await injectSshIdentity(taskId, keyPath, exec);
167
+ // Fast path: key already good → re-assert only the (cheap, idempotent)
168
+ // git config and skip the docker cp churn that causes the ownership race.
169
+ if (await sshKeyHealthy(taskId, exec)) {
170
+ await exec([
171
+ "exec",
172
+ "-u",
173
+ "node",
174
+ `task-${taskId}-app-1`,
175
+ "sh",
176
+ "-c",
177
+ SSH_GIT_CONFIG,
178
+ ]);
179
+ return true;
180
+ }
181
+
182
+ let keyPath: string | null = null;
183
+ let perTask = false;
184
+ const dir = writeTaskIdentity(taskId, ownerUserId);
185
+ if (dir) {
186
+ keyPath = resolve(dir, "id_ed25519");
187
+ perTask = true;
188
+ } else {
189
+ const operatorKey = resolve(env.dataDir, "identity", "id_ed25519");
190
+ if (existsSync(operatorKey)) keyPath = operatorKey;
191
+ }
192
+ if (!keyPath) {
193
+ console.log(
194
+ `[ssh] task ${taskId}: no SSH identity for owner or operator — git pushes stay on HTTPS`,
195
+ );
196
+ return false;
197
+ }
198
+ try {
199
+ return await injectSshIdentity(taskId, keyPath, exec);
200
+ } finally {
201
+ // Host hygiene (same as task-up): the materialized private key never
202
+ // outlives the injection.
203
+ if (perTask) removeTaskIdentity(taskId);
204
+ }
155
205
  } finally {
156
- // Host hygiene (same as task-up): the materialized private key never
157
- // outlives the injection.
158
- if (perTask) removeTaskIdentity(taskId);
206
+ sshEnsureInFlight.delete(taskId);
159
207
  }
160
208
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.8.22",
3
+ "version": "0.8.24",
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>",