@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 +1 -1
- package/src/cli.ts +242 -1
- package/src/main.ts +12 -0
- package/src/protocol.ts +8 -0
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -20,7 +20,11 @@
|
|
|
20
20
|
// runs far too late.)
|
|
21
21
|
import "./load-env";
|
|
22
22
|
|
|
23
|
-
import {
|
|
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;
|