@runuai/host 0.9.74 → 0.9.76

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.
@@ -22,6 +22,8 @@
22
22
  import { spawn } from "node:child_process";
23
23
 
24
24
  import type { DockerResult } from "./docker-exec";
25
+ import { createHash } from "node:crypto";
26
+
25
27
  import type {
26
28
  MachineInfo,
27
29
  MachineProvider,
@@ -103,7 +105,12 @@ export function awsUserData(authorizedPublicKey: string): string {
103
105
  const script = [
104
106
  "#!/bin/sh",
105
107
  "set -e",
106
- "id node >/dev/null 2>&1 || useradd -m -u 1000 -s /bin/bash node",
108
+ // uid 1000 is often TAKEN on stock cloud images (Debian's `admin`)
109
+ // insisting on it aborted the whole script under set -e and left no key
110
+ // installed for anyone (live 2026-08-27, first EC2 smoke). Prefer 1000,
111
+ // fall back to auto: on a machine the task owns the whole filesystem,
112
+ // so the exact uid is convention, not correctness.
113
+ "id node >/dev/null 2>&1 || useradd -m -u 1000 -s /bin/bash node 2>/dev/null || useradd -m -s /bin/bash node",
107
114
  "for account in node root; do",
108
115
  ' home=$(getent passwd "$account" | cut -d: -f6)',
109
116
  ' mkdir -p "$home/.ssh"',
@@ -114,7 +121,12 @@ export function awsUserData(authorizedPublicKey: string): string {
114
121
  "done",
115
122
  "",
116
123
  ].join("\n");
117
- return Buffer.from(script, "utf8").toString("base64");
124
+ // RAW script, not base64: AWS CLI v2 base64-encodes --user-data ITSELF.
125
+ // Pre-encoding double-encoded it, cloud-init saw garbage and silently ran
126
+ // nothing — no key installed for anyone (live 2026-08-27, second EC2
127
+ // smoke failure; the uid-1000 collision fixed just before was real too,
128
+ // but masked by this).
129
+ return script;
118
130
  }
119
131
 
120
132
  type LiveInstance = {
@@ -144,6 +156,7 @@ function parseReservations(stdout: string): LiveInstance[] | null {
144
156
  InstanceId?: unknown;
145
157
  State?: { Name?: unknown };
146
158
  PrivateIpAddress?: unknown;
159
+ PublicIpAddress?: unknown;
147
160
  Tags?: Array<{ Key?: unknown; Value?: unknown }>;
148
161
  };
149
162
  if (typeof record.InstanceId !== "string") return null;
@@ -162,10 +175,16 @@ function parseReservations(stdout: string): LiveInstance[] | null {
162
175
  instances.push({
163
176
  instanceId: record.InstanceId,
164
177
  state,
178
+ // Prefer the public address when the subnet assigns one: an
179
+ // orchestrator OUTSIDE the VPC (the local smoke, a dev box) can only
180
+ // dial that, while the production in-VPC orchestrator sees no public
181
+ // IP on its machines and keeps dialing private, unchanged.
165
182
  privateIp:
166
- typeof record.PrivateIpAddress === "string"
167
- ? record.PrivateIpAddress
168
- : null,
183
+ typeof record.PublicIpAddress === "string"
184
+ ? record.PublicIpAddress
185
+ : typeof record.PrivateIpAddress === "string"
186
+ ? record.PrivateIpAddress
187
+ : null,
169
188
  taskLabel,
170
189
  });
171
190
  }
@@ -265,6 +284,28 @@ export function createAwsMachineProvider(
265
284
  async launch(spec: MachineSpec): Promise<MachineInfo> {
266
285
  const machineId = awsMachineName(spec.taskId);
267
286
  const type = (config.instanceType ?? defaultInstanceType)(spec);
287
+ // The idempotency token binds the machine id TO these exact launch
288
+ // arguments: EC2 remembers a client token's arguments for ~24h (even
289
+ // past termination) and rejects reuse with different ones
290
+ // (IdempotentParameterMismatch — live 2026-08-27: a Resume after a
291
+ // provider fix changed the user-data and the retry was refused). An
292
+ // identical retry still cannot mint a second instance; a CHANGED
293
+ // launch legitimately gets a fresh token, and the resume-aware
294
+ // provision's describe-first step remains the guard against doubling
295
+ // a live machine.
296
+ const launchFingerprint = createHash("sha256")
297
+ .update(
298
+ JSON.stringify([
299
+ spec.image,
300
+ type,
301
+ spec.authorizedPublicKey ?? null,
302
+ config.subnetId ?? null,
303
+ config.securityGroupId ?? null,
304
+ config.iamInstanceProfileArn ?? null,
305
+ ]),
306
+ )
307
+ .digest("hex")
308
+ .slice(0, 16);
268
309
  const args = [
269
310
  "ec2",
270
311
  "run-instances",
@@ -277,7 +318,7 @@ export function createAwsMachineProvider(
277
318
  // Idempotency across crashed/retried provisions: the same token can
278
319
  // never mint a second instance.
279
320
  "--client-token",
280
- machineId,
321
+ `${machineId}-${launchFingerprint}`.slice(0, 64),
281
322
  "--tag-specifications",
282
323
  `ResourceType=instance,Tags=[{Key=${AWS_MACHINE_TAG},Value=${spec.taskId}},{Key=Name,Value=${machineId}}]`,
283
324
  ...(spec.authorizedPublicKey
@@ -325,6 +366,38 @@ export function createAwsMachineProvider(
325
366
  }`,
326
367
  );
327
368
  }
369
+ // EC2 sits in `stopping` for tens of seconds, and StartInstances
370
+ // refuses it with IncorrectInstanceState — an immediate stop→resume
371
+ // (a user pressing Resume right after Stop; the live 2026-08-27
372
+ // smoke) failed exactly there because our state map folds `stopping`
373
+ // into "stopped". Stop resolves only once the instance can actually
374
+ // be started again, mirroring terminate's poll-to-absent.
375
+ const deadline = Date.now() + 180_000;
376
+ for (;;) {
377
+ const raw = await runner([
378
+ "ec2",
379
+ "describe-instances",
380
+ "--instance-ids",
381
+ instance.instanceId,
382
+ "--query",
383
+ "Reservations[].Instances[].State.Name",
384
+ "--output",
385
+ "text",
386
+ ]);
387
+ const state = raw.status === 0 ? raw.stdout.trim() : "";
388
+ if (state === "stopped") return;
389
+ if (state === "terminated" || state === "shutting-down") {
390
+ throw new Error(
391
+ `machine ${machineId} terminated while stopping (${state})`,
392
+ );
393
+ }
394
+ if (Date.now() >= deadline) {
395
+ throw new Error(
396
+ `machine ${machineId} did not reach stopped within the stop deadline (${state || "unknown"})`,
397
+ );
398
+ }
399
+ await new Promise<void>((resolve) => setTimeout(resolve, 5_000));
400
+ }
328
401
  },
329
402
 
330
403
  async start(machineId: string): Promise<MachineInfo> {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.74",
3
+ "version": "0.9.76",
4
4
  "description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",