@runuai/host 0.9.82 → 0.9.84

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.82",
3
+ "version": "0.9.84",
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
package/src/main.ts CHANGED
@@ -598,6 +598,18 @@ function buildCapabilities(): HostCapabilities {
598
598
  // advertise them as usable while the machine cannot run a container.
599
599
  runtimes: runtime.status === "ready" ? standardRuntimes() : [],
600
600
  containerRuntime: runtime,
601
+ // ADR-125: surface machine backing so the host page can say "AWS"
602
+ // instead of implying containers are the whole story.
603
+ ...(process.env.UAI_MACHINE_TASKS === "1"
604
+ ? {
605
+ machineTasks: {
606
+ provider: process.env.UAI_MACHINE_PROVIDER ?? "local",
607
+ ...(process.env.UAI_AWS_REGION
608
+ ? { region: process.env.UAI_AWS_REGION }
609
+ : {}),
610
+ },
611
+ }
612
+ : {}),
601
613
  maintenanceReady: areAgentClisReady(),
602
614
  mcpGateway: mcpGateway.state(),
603
615
  engineLogins: engineLoginManager.capabilities(),
package/src/protocol.ts CHANGED
@@ -943,6 +943,14 @@ export interface HostCapabilities {
943
943
  /** ADR-101: the machine-level container backend. Optional for rolling
944
944
  * compatibility with hosts predating runtime detection. */
945
945
  containerRuntime?: ContainerRuntimeCapability;
946
+ /** ADR-125: machine-backed tasks (each task on its own ephemeral machine).
947
+ * Present when the host is configured for them; the cloud shows the
948
+ * provider/region on the host page and, eventually, per-task backing
949
+ * choice keys off it. */
950
+ machineTasks?: {
951
+ provider: string;
952
+ region?: string;
953
+ };
946
954
  /** ADR-100: one-way boot latch. It becomes true after the initial shared
947
955
  * image/CLI maintenance pass settles, including a best-effort failure. */
948
956
  maintenanceReady?: boolean;