@runuai/host 0.9.81 → 0.9.83

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.
@@ -33,6 +33,17 @@ import type {
33
33
 
34
34
  export const AWS_MACHINE_TAG = "com.uai.machine";
35
35
 
36
+ /** ADR-125 BYO-AWS: the Uai account that publishes the public payload AMIs,
37
+ * and the name prefix build-ami.sh stamps on them. A host with no
38
+ * UAI_MACHINE_IMAGE resolves the newest published payload in its region. */
39
+ export const PAYLOAD_AMI_OWNER = "277057415208";
40
+ export const PAYLOAD_AMI_NAME_PREFIX = "uai-machine-payload";
41
+ /** Name of the security group auto-created in the default VPC when the host
42
+ * config names none. Inbound: all TCP from the HOST's egress /32 only —
43
+ * code-server runs auth-none and preview ports are plain HTTP, so exposure
44
+ * wider than the orchestrator would hand the editor to the internet. */
45
+ const MANAGED_SECURITY_GROUP = "uai-machines";
46
+
36
47
  export interface AwsMachineConfig {
37
48
  region: string;
38
49
  /** AMI id used when the spec's image is not already an ami-*. */
@@ -219,6 +230,30 @@ function unknown(id: string, detail: string): MachineInfo {
219
230
  return { id, taskLabel: null, state: "unknown", address: null, detail };
220
231
  }
221
232
 
233
+ /** The host's public egress address, for scoping the auto-created security
234
+ * group. Only consulted when the group must be CREATED — an existing group
235
+ * (explicit config or a prior run) never re-resolves. Overridable for hosts
236
+ * whose egress the checker cannot see (e.g. VPN'd). */
237
+ async function hostEgressIp(): Promise<string> {
238
+ const configured = process.env.UAI_HOST_EGRESS_IP;
239
+ if (configured && /^\d{1,3}(\.\d{1,3}){3}$/.test(configured)) {
240
+ return configured;
241
+ }
242
+ try {
243
+ const res = await fetch("https://checkip.amazonaws.com", {
244
+ signal: AbortSignal.timeout(5_000),
245
+ });
246
+ const ip = (await res.text()).trim();
247
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) return ip;
248
+ } catch {
249
+ // fall through to the instruction below
250
+ }
251
+ throw new Error(
252
+ "could not determine this host's public address to scope the machine " +
253
+ "security group — set UAI_HOST_EGRESS_IP or UAI_AWS_SECURITY_GROUP_ID",
254
+ );
255
+ }
256
+
222
257
  export function createAwsMachineProvider(
223
258
  config: AwsMachineConfig,
224
259
  runner: Runner = defaultRunner(config.region),
@@ -265,6 +300,171 @@ export function createAwsMachineProvider(
265
300
  return device;
266
301
  }
267
302
 
303
+ // -------------------------------------------------------------------------
304
+ // ADR-125 BYO-AWS zero-config discovery. Every resolver prefers explicit
305
+ // host config, resolves once per process, and fails with an instruction
306
+ // rather than a guess — a wrong subnet or security group is a machine the
307
+ // orchestrator can never reach.
308
+ // -------------------------------------------------------------------------
309
+
310
+ let resolvedImage: string | null = null;
311
+ async function resolveImage(specImage: string): Promise<string> {
312
+ if (specImage.startsWith("ami-")) return specImage;
313
+ if (resolvedImage) return resolvedImage;
314
+ const res = await runner([
315
+ "ec2",
316
+ "describe-images",
317
+ "--owners",
318
+ PAYLOAD_AMI_OWNER,
319
+ "--filters",
320
+ `Name=name,Values=${PAYLOAD_AMI_NAME_PREFIX}-*`,
321
+ "Name=state,Values=available",
322
+ "--query",
323
+ "sort_by(Images, &CreationDate)[-1].ImageId",
324
+ ]);
325
+ let image: unknown = null;
326
+ if (res.status === 0) {
327
+ try {
328
+ image = JSON.parse(res.stdout);
329
+ } catch {
330
+ image = null;
331
+ }
332
+ }
333
+ if (typeof image !== "string" || !image.startsWith("ami-")) {
334
+ throw new Error(
335
+ `no uai machine payload AMI is published in ${config.region} — set ` +
336
+ "UAI_MACHINE_IMAGE to an AMI id, or ask Uai to publish this region",
337
+ );
338
+ }
339
+ resolvedImage = image;
340
+ return image;
341
+ }
342
+
343
+ let resolvedSubnet: string | null = null;
344
+ async function resolveSubnet(): Promise<string> {
345
+ if (config.subnetId) return config.subnetId;
346
+ if (resolvedSubnet) return resolvedSubnet;
347
+ const res = await runner([
348
+ "ec2",
349
+ "describe-subnets",
350
+ "--filters",
351
+ "Name=default-for-az,Values=true",
352
+ "--query",
353
+ "sort_by(Subnets, &AvailabilityZone)[].{id: SubnetId, public: MapPublicIpOnLaunch, vpc: VpcId}",
354
+ ]);
355
+ let subnets: Array<{ id?: unknown; public?: unknown }> = [];
356
+ if (res.status === 0) {
357
+ try {
358
+ const parsed = JSON.parse(res.stdout);
359
+ if (Array.isArray(parsed)) subnets = parsed;
360
+ } catch {
361
+ subnets = [];
362
+ }
363
+ }
364
+ const chosen =
365
+ subnets.find((s) => s.public === true && typeof s.id === "string") ??
366
+ subnets.find((s) => typeof s.id === "string");
367
+ if (!chosen || typeof chosen.id !== "string") {
368
+ throw new Error(
369
+ `no default-VPC subnet found in ${config.region} — set ` +
370
+ "UAI_AWS_SUBNET_ID to a public subnet",
371
+ );
372
+ }
373
+ resolvedSubnet = chosen.id;
374
+ return chosen.id;
375
+ }
376
+
377
+ let resolvedSecurityGroup: string | null = null;
378
+ async function resolveSecurityGroup(subnetId: string): Promise<string> {
379
+ if (config.securityGroupId) return config.securityGroupId;
380
+ if (resolvedSecurityGroup) return resolvedSecurityGroup;
381
+ const vpcRes = await runner([
382
+ "ec2",
383
+ "describe-subnets",
384
+ "--subnet-ids",
385
+ subnetId,
386
+ "--query",
387
+ "Subnets[0].VpcId",
388
+ ]);
389
+ const vpcId = vpcRes.status === 0 ? JSON.parse(vpcRes.stdout) : null;
390
+ if (typeof vpcId !== "string") {
391
+ throw new Error(`could not resolve the VPC of subnet ${subnetId}`);
392
+ }
393
+ const found = await runner([
394
+ "ec2",
395
+ "describe-security-groups",
396
+ "--filters",
397
+ `Name=group-name,Values=${MANAGED_SECURITY_GROUP}`,
398
+ `Name=vpc-id,Values=${vpcId}`,
399
+ "--query",
400
+ "SecurityGroups[0].GroupId",
401
+ ]);
402
+ if (found.status === 0) {
403
+ try {
404
+ const existing = JSON.parse(found.stdout);
405
+ if (typeof existing === "string" && existing.startsWith("sg-")) {
406
+ resolvedSecurityGroup = existing;
407
+ return existing;
408
+ }
409
+ } catch {
410
+ // fall through to creation
411
+ }
412
+ }
413
+ const egress = await hostEgressIp();
414
+ const created = await runner([
415
+ "ec2",
416
+ "create-security-group",
417
+ "--group-name",
418
+ MANAGED_SECURITY_GROUP,
419
+ "--description",
420
+ "uai task machines: reachable only from the orchestrating host",
421
+ "--vpc-id",
422
+ vpcId,
423
+ "--query",
424
+ "GroupId",
425
+ ]);
426
+ const groupId = created.status === 0 ? JSON.parse(created.stdout) : null;
427
+ if (typeof groupId !== "string") {
428
+ throw new Error(
429
+ `could not create the ${MANAGED_SECURITY_GROUP} security group: ${
430
+ created.stderr.trim() || `exit ${created.status ?? "killed"}`
431
+ }`,
432
+ );
433
+ }
434
+ const authorized = await runner([
435
+ "ec2",
436
+ "authorize-security-group-ingress",
437
+ "--group-id",
438
+ groupId,
439
+ "--ip-permissions",
440
+ JSON.stringify([
441
+ {
442
+ IpProtocol: "tcp",
443
+ FromPort: 0,
444
+ ToPort: 65535,
445
+ IpRanges: [
446
+ {
447
+ CidrIp: `${egress}/32`,
448
+ Description: "uai orchestrating host",
449
+ },
450
+ ],
451
+ },
452
+ ]),
453
+ ]);
454
+ if (authorized.status !== 0) {
455
+ throw new Error(
456
+ `created ${groupId} but could not authorize the host's address: ${
457
+ authorized.stderr.trim() || `exit ${authorized.status ?? "killed"}`
458
+ }`,
459
+ );
460
+ }
461
+ console.log(
462
+ `[machine-aws] created security group ${groupId} (${MANAGED_SECURITY_GROUP}) allowing ${egress}/32`,
463
+ );
464
+ resolvedSecurityGroup = groupId;
465
+ return groupId;
466
+ }
467
+
268
468
  /** Resolve the logical machine id to its live instances via the tag. */
269
469
  async function resolveLive(
270
470
  machineId: string,
@@ -338,16 +538,21 @@ export function createAwsMachineProvider(
338
538
  // name the AMI's actual root device — a wrong name would ADD a blank
339
539
  // volume while the root stays small — so resolve it per image.
340
540
  const diskGiB = config.diskGiB ?? DEFAULT_DISK_GIB;
341
- const rootDevice = await rootDeviceName(spec.image);
541
+ // ADR-125 BYO-AWS: explicit config wins; otherwise resolve the newest
542
+ // published payload AMI and the default-VPC network per process.
543
+ const image = await resolveImage(spec.image);
544
+ const subnetId = await resolveSubnet();
545
+ const securityGroupId = await resolveSecurityGroup(subnetId);
546
+ const rootDevice = await rootDeviceName(image);
342
547
  // a live machine.
343
548
  const launchFingerprint = createHash("sha256")
344
549
  .update(
345
550
  JSON.stringify([
346
- spec.image,
551
+ image,
347
552
  type,
348
553
  spec.authorizedPublicKey ?? null,
349
- config.subnetId ?? null,
350
- config.securityGroupId ?? null,
554
+ subnetId,
555
+ securityGroupId,
351
556
  config.iamInstanceProfileArn ?? null,
352
557
  diskGiB,
353
558
  ]),
@@ -358,7 +563,7 @@ export function createAwsMachineProvider(
358
563
  "ec2",
359
564
  "run-instances",
360
565
  "--image-id",
361
- spec.image,
566
+ image,
362
567
  "--instance-type",
363
568
  type,
364
569
  "--count",
@@ -383,10 +588,10 @@ export function createAwsMachineProvider(
383
588
  ...(spec.authorizedPublicKey
384
589
  ? ["--user-data", awsUserData(spec.authorizedPublicKey)]
385
590
  : []),
386
- ...(config.subnetId ? ["--subnet-id", config.subnetId] : []),
387
- ...(config.securityGroupId
388
- ? ["--security-group-ids", config.securityGroupId]
389
- : []),
591
+ "--subnet-id",
592
+ subnetId,
593
+ "--security-group-ids",
594
+ securityGroupId,
390
595
  ...(config.iamInstanceProfileArn
391
596
  ? ["--iam-instance-profile", `Arn=${config.iamInstanceProfileArn}`]
392
597
  : []),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.81",
3
+ "version": "0.9.83",
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>",
package/src/cli.ts CHANGED
@@ -20,7 +20,11 @@
20
20
  // runs far too late.)
21
21
  import "./load-env";
22
22
 
23
- import { spawn, spawnSync } from "node:child_process";
23
+ import {
24
+ spawn,
25
+ spawnSync,
26
+ type SpawnSyncReturns,
27
+ } from "node:child_process";
24
28
  import { createHash, randomBytes } from "node:crypto";
25
29
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
26
30
  import { homedir, hostname } from "node:os";
@@ -139,6 +143,11 @@ async function main(): Promise<void> {
139
143
  return cmdRuntime(rest);
140
144
  case "tune":
141
145
  return cmdTune();
146
+ case "aws":
147
+ if (rest[0] === "setup") return cmdAwsSetup(rest.slice(1));
148
+ console.error(red("usage: uai-host aws setup [--region <region>] [--rotate]"));
149
+ process.exitCode = 1;
150
+ return;
142
151
  case "update":
143
152
  return cmdUpdate(rest);
144
153
  case "rollback":
@@ -500,6 +509,234 @@ async function boundedResponseJson(
500
509
 
501
510
  // --- runtime ---------------------------------------------------------------
502
511
 
512
+ // --- aws setup (ADR-125 BYO-AWS: mint the host's scoped AWS identity) --------
513
+
514
+ /** Mirrors scripts/machine-ami/byo-aws-quickcreate.yaml — keep in sync. */
515
+ const AWS_MACHINE_POLICY = {
516
+ Version: "2012-10-17",
517
+ Statement: [
518
+ {
519
+ Sid: "UaiMachines",
520
+ Effect: "Allow",
521
+ Action: [
522
+ "ec2:RunInstances",
523
+ "ec2:StartInstances",
524
+ "ec2:StopInstances",
525
+ "ec2:TerminateInstances",
526
+ "ec2:CreateTags",
527
+ "ec2:DescribeInstances",
528
+ "ec2:DescribeImages",
529
+ "ec2:DescribeSubnets",
530
+ "ec2:DescribeVpcs",
531
+ "ec2:DescribeSecurityGroups",
532
+ "ec2:CreateSecurityGroup",
533
+ "ec2:AuthorizeSecurityGroupIngress",
534
+ "ec2:CreateSnapshot",
535
+ "ec2:DeleteSnapshot",
536
+ "ec2:CreateVolume",
537
+ "ec2:DescribeSnapshots",
538
+ "ec2:DescribeVolumes",
539
+ ],
540
+ Resource: "*",
541
+ },
542
+ ],
543
+ };
544
+ const AWS_SETUP_USER = "uai-host";
545
+
546
+ /** Run the aws CLI with the operator's AMBIENT (admin) credentials: any
547
+ * AWS_* that load-env pulled from .env.local is a previously-minted SCOPED
548
+ * key that cannot administer IAM, and letting it shadow the shell's admin
549
+ * credentials makes every failure read as a permissions mystery. */
550
+ function awsAdmin(
551
+ args: string[],
552
+ region: string | null,
553
+ ): SpawnSyncReturns<string> {
554
+ const env = { ...process.env };
555
+ const fromFile = (() => {
556
+ try {
557
+ return parseDotenv(readFileSync(envLocalPath(), "utf8"));
558
+ } catch {
559
+ return {} as Record<string, string>;
560
+ }
561
+ })();
562
+ if (
563
+ env.AWS_ACCESS_KEY_ID &&
564
+ env.AWS_ACCESS_KEY_ID === fromFile.AWS_ACCESS_KEY_ID
565
+ ) {
566
+ delete env.AWS_ACCESS_KEY_ID;
567
+ delete env.AWS_SECRET_ACCESS_KEY;
568
+ delete env.AWS_SESSION_TOKEN;
569
+ }
570
+ return spawnSync(
571
+ "aws",
572
+ [...(region ? ["--region", region] : []), "--output", "json", ...args],
573
+ { encoding: "utf8", env },
574
+ );
575
+ }
576
+
577
+ async function cmdAwsSetup(rest: string[]): Promise<void> {
578
+ const rotate = rest.includes("--rotate");
579
+ const regionFlagAt = rest.indexOf("--region");
580
+ let region = regionFlagAt >= 0 ? (rest[regionFlagAt + 1] ?? "") : "";
581
+
582
+ const version = spawnSync("aws", ["--version"], { encoding: "utf8" });
583
+ if (version.status !== 0) {
584
+ console.error(red("the aws CLI is not installed (or not on PATH)"));
585
+ console.error("install it: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html");
586
+ process.exitCode = 1;
587
+ return;
588
+ }
589
+
590
+ if (!region) {
591
+ const configured = spawnSync("aws", ["configure", "get", "region"], {
592
+ encoding: "utf8",
593
+ });
594
+ region = (configured.stdout ?? "").trim();
595
+ }
596
+ if (!region) {
597
+ console.error(red("no AWS region — pass --region <region> (e.g. --region us-west-2)"));
598
+ process.exitCode = 1;
599
+ return;
600
+ }
601
+
602
+ const identity = awsAdmin(["sts", "get-caller-identity"], region);
603
+ if (identity.status !== 0) {
604
+ console.error(red("no working AWS admin credentials in this shell"));
605
+ console.error(
606
+ "sign in first (aws configure, aws sso login, or exported AWS_* vars), then re-run",
607
+ );
608
+ console.error(dim((identity.stderr ?? "").trim().slice(0, 200)));
609
+ process.exitCode = 1;
610
+ return;
611
+ }
612
+ const account = (() => {
613
+ try {
614
+ return (JSON.parse(identity.stdout) as { Account?: string }).Account ?? "?";
615
+ } catch {
616
+ return "?";
617
+ }
618
+ })();
619
+
620
+ const existingEnv = (() => {
621
+ try {
622
+ return parseDotenv(readFileSync(envLocalPath(), "utf8"));
623
+ } catch {
624
+ return {} as Record<string, string>;
625
+ }
626
+ })();
627
+ if (existingEnv.AWS_ACCESS_KEY_ID && !rotate) {
628
+ console.log(
629
+ `already configured (key ${existingEnv.AWS_ACCESS_KEY_ID.slice(0, 8)}…, region ${existingEnv.UAI_AWS_REGION ?? "?"})`,
630
+ );
631
+ console.log(`re-run with ${bold("--rotate")} to mint a fresh key`);
632
+ return;
633
+ }
634
+
635
+ console.log(`account ${account}, region ${region} — creating the ${AWS_SETUP_USER} identity`);
636
+
637
+ const created = awsAdmin(
638
+ ["iam", "create-user", "--user-name", AWS_SETUP_USER],
639
+ region,
640
+ );
641
+ if (
642
+ created.status !== 0 &&
643
+ !(created.stderr ?? "").includes("EntityAlreadyExists")
644
+ ) {
645
+ console.error(red(`could not create the ${AWS_SETUP_USER} IAM user: ${(created.stderr ?? "").trim().slice(0, 300)}`));
646
+ process.exitCode = 1;
647
+ return;
648
+ }
649
+
650
+ // put-user-policy overwrites: re-running upgrades the policy in place.
651
+ const policy = awsAdmin(
652
+ [
653
+ "iam",
654
+ "put-user-policy",
655
+ "--user-name",
656
+ AWS_SETUP_USER,
657
+ "--policy-name",
658
+ "uai-machines",
659
+ "--policy-document",
660
+ JSON.stringify(AWS_MACHINE_POLICY),
661
+ ],
662
+ region,
663
+ );
664
+ if (policy.status !== 0) {
665
+ console.error(red(`could not attach the machine policy: ${(policy.stderr ?? "").trim().slice(0, 300)}`));
666
+ process.exitCode = 1;
667
+ return;
668
+ }
669
+
670
+ const keyResult = awsAdmin(
671
+ ["iam", "create-access-key", "--user-name", AWS_SETUP_USER],
672
+ region,
673
+ );
674
+ if (keyResult.status !== 0) {
675
+ console.error(red(`could not create an access key: ${(keyResult.stderr ?? "").trim().slice(0, 300)}`));
676
+ if ((keyResult.stderr ?? "").includes("LimitExceeded")) {
677
+ console.error(
678
+ `the user already has two keys — list them with: aws iam list-access-keys --user-name ${AWS_SETUP_USER}\n` +
679
+ `and delete one with: aws iam delete-access-key --user-name ${AWS_SETUP_USER} --access-key-id <id>`,
680
+ );
681
+ }
682
+ process.exitCode = 1;
683
+ return;
684
+ }
685
+ let keyId = "";
686
+ let secret = "";
687
+ try {
688
+ const parsed = JSON.parse(keyResult.stdout) as {
689
+ AccessKey?: { AccessKeyId?: string; SecretAccessKey?: string };
690
+ };
691
+ keyId = parsed.AccessKey?.AccessKeyId ?? "";
692
+ secret = parsed.AccessKey?.SecretAccessKey ?? "";
693
+ } catch {
694
+ // handled below
695
+ }
696
+ if (!keyId || !secret) {
697
+ console.error(red("create-access-key returned no key material"));
698
+ process.exitCode = 1;
699
+ return;
700
+ }
701
+
702
+ writeEnvValuesAtomic(envLocalPath(), {
703
+ UAI_MACHINE_TASKS: "1",
704
+ UAI_MACHINE_PROVIDER: "aws",
705
+ UAI_AWS_REGION: region,
706
+ AWS_ACCESS_KEY_ID: keyId,
707
+ AWS_SECRET_ACCESS_KEY: secret,
708
+ });
709
+
710
+ // Rotation hygiene: the key the env file carried before this run belongs
711
+ // to the scoped user; a minted replacement leaves it orphaned AND holding
712
+ // the 2-key limit. Best-effort delete, loudly skippable.
713
+ const previousKey = existingEnv.AWS_ACCESS_KEY_ID;
714
+ if (rotate && previousKey && previousKey !== keyId) {
715
+ const deleted = awsAdmin(
716
+ [
717
+ "iam",
718
+ "delete-access-key",
719
+ "--user-name",
720
+ AWS_SETUP_USER,
721
+ "--access-key-id",
722
+ previousKey,
723
+ ],
724
+ region,
725
+ );
726
+ if (deleted.status === 0) {
727
+ console.log(`rotated: deleted the previous key ${previousKey.slice(0, 8)}…`);
728
+ } else {
729
+ console.log(
730
+ yellow(`minted a new key but could not delete ${previousKey.slice(0, 8)}… — remove it manually if unused`),
731
+ );
732
+ }
733
+ }
734
+
735
+ console.log(`${bold("done")} — scoped credentials written to ${envLocalPath()}`);
736
+ console.log(` UAI_MACHINE_PROVIDER=aws UAI_AWS_REGION=${region} (machine tasks enabled)`);
737
+ console.log(`restart the host to activate: ${cyan("uai-host restart")}`);
738
+ }
739
+
503
740
  // --- tune (ADR-118 follow-up: vnode/file-table ceilings) ---------------------
504
741
 
505
742
  const TUNE_DAEMON_LABEL = "com.runuai.tune";
@@ -2019,6 +2256,10 @@ function printHelp(): void {
2019
2256
  runtime recheck probe Docker again and refresh cloud capabilities
2020
2257
  tune (macOS, sudo) raise kernel vnode/file-table ceilings for
2021
2258
  apple-container task VMs and persist them across boots
2259
+ aws setup [--region <r>] [--rotate]
2260
+ enable machine-backed tasks in YOUR AWS account: uses
2261
+ the admin credentials already in this shell to mint a
2262
+ scoped uai-host IAM key and writes the host config
2022
2263
  setup --cloud <wss-url> --enroll <token>
2023
2264
  claim this machine via an enrollment token from the web
2024
2265
  app (mints + writes the host credential); on an