warpmetal 0.4.2 → 0.6.0

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/src/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { readFile } from "node:fs/promises";
3
- import { resolve } from "node:path";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import { basename, join, resolve } from "node:path";
4
4
 
5
5
  import {
6
6
  booleanOption,
@@ -30,8 +30,11 @@ import {
30
30
  import {
31
31
  connectSandbox,
32
32
  generateSandboxKey,
33
+ generateServerKey,
33
34
  readSshPublicKey,
35
+ serverKeyName,
34
36
  signSshChallenge,
37
+ sshFingerprint,
35
38
  } from "./ssh.js";
36
39
  import { resolveStateDirectory, StateStore } from "./state.js";
37
40
  import { VERSION } from "./version.js";
@@ -70,23 +73,36 @@ Usage:
70
73
  warpmetal health [--json]
71
74
  warpmetal catalog [--plan <planId>] [--json]
72
75
  warpmetal order prepare --plan <planId> --hostname <name> --os <exact-name>
73
- --ssh-public-key-file <path> [--runtime-file <path>] [--confirm TEMPORARY]
76
+ (--generate-ssh-key [--ssh-key-name <name>] | --ssh-public-key-file <path>)
77
+ [--runtime-file <path>] [--confirm TEMPORARY]
74
78
  [--email <address>] [--idempotency-key <key>]
75
79
  warpmetal order status --task <taskId> [--wait] [--timeout-seconds <n>]
76
80
  warpmetal checkout challenge --task <taskId> [--request-envelope-out <path>]
77
81
  warpmetal checkout submit --task <taskId>
78
82
  (--payment-artifact <path> | --payment-signature-file <path>)
79
83
  [--wait] [--timeout-seconds <n>]
80
- warpmetal server login --server <serverId> --identity <private-key-path>
84
+ warpmetal renewal configure --server <serverId> --renew-before-days <n>
85
+ --maximum-payment-atomic <amount> (--maximum-renewals <n> | --renew-through <UTC>)
86
+ --allowed-network <CAIP-2> --allowed-asset <asset> --wallet <name>
87
+ warpmetal renewal status|prepare|submit|run ...
88
+ warpmetal renewal due (--server <serverId> | --all)
89
+ warpmetal notifications configure --server <serverId> --email <address>
90
+ warpmetal notifications status --server <serverId>
91
+ warpmetal identity generate --hostname <name> [--ssh-key-name <name>]
92
+ warpmetal identity list
93
+ warpmetal server identity --server <serverId>
94
+ warpmetal server identity attach --server <serverId> --identity <private-key-path>
95
+ warpmetal server login --server <serverId> [--identity <private-key-path>]
81
96
  warpmetal server get --server <serverId>
82
97
  warpmetal server power --server <serverId> --action <boot|reboot|shutdown>
83
98
  --confirm <same-action> [--wait] [--idempotency-key <key>]
84
99
  warpmetal server reload --server <serverId> --confirm ERASE --power-off-first
85
100
  [--acknowledge-agent-runtime-reset] [--hostname <name>] [--os <exact-name>]
86
- [--ssh-public-key-file <path>] [--wait] [--idempotency-key <key>]
101
+ [--generate-ssh-key [--ssh-key-name <name>] | --ssh-public-key-file <path>]
102
+ [--wait] [--idempotency-key <key>]
87
103
  warpmetal operation get --operation <operationId> [--server <serverId>] [--wait]
88
104
  warpmetal runtime enable|get --server <serverId> [--wait]
89
- warpmetal runtime install --server <serverId> --identity <owner-key> --ssh-user <user>
105
+ warpmetal runtime install --server <serverId> [--identity <owner-key>] --ssh-user <user>
90
106
  --confirm INSTALL [--wait]
91
107
  warpmetal sandbox create --server <serverId> --name <name> --size <size>
92
108
  [--lifetime temporary] [--expires-in-seconds <n>] [--confirm TEMPORARY] [--wait]
@@ -429,13 +445,88 @@ async function handleCatalog(client, options, { json, stdout }) {
429
445
  return 0;
430
446
  }
431
447
 
448
+ async function pathExists(path) {
449
+ try {
450
+ await stat(path);
451
+ return true;
452
+ } catch (error) {
453
+ if (error?.code === "ENOENT") return false;
454
+ throw error;
455
+ }
456
+ }
457
+
458
+ function identityId() {
459
+ return `idn_${randomUUID()}`;
460
+ }
461
+
462
+ async function generatedIdentity(store, hostname, options, context) {
463
+ const generated = await generateServerKey(
464
+ join(store.directory, "ssh"),
465
+ hostname,
466
+ stringOption(options, "ssh-key-name"),
467
+ { spawn: context.spawnSyncImpl },
468
+ );
469
+ const identity = {
470
+ identityId: identityId(),
471
+ ...generated,
472
+ generated: true,
473
+ };
474
+ delete identity.sshPublicKey;
475
+ await store.saveIdentity(identity);
476
+ return { identity, sshPublicKey: generated.sshPublicKey };
477
+ }
478
+
479
+ async function existingIdentity(store, hostname, publicKeyFile, requestedName) {
480
+ const publicKeyPath = resolve(publicKeyFile);
481
+ const sshPublicKey = await readSshPublicKey(publicKeyPath);
482
+ const candidatePrivatePath = publicKeyPath.endsWith(".pub")
483
+ ? publicKeyPath.slice(0, -4)
484
+ : undefined;
485
+ const keyName = serverKeyName(
486
+ hostname,
487
+ requestedName || basename(candidatePrivatePath || publicKeyPath),
488
+ ).keyName;
489
+ const identity = {
490
+ identityId: identityId(),
491
+ keyName,
492
+ hostnameAtCreation: hostname.toLowerCase(),
493
+ privateKeyPath:
494
+ candidatePrivatePath && (await pathExists(candidatePrivatePath))
495
+ ? candidatePrivatePath
496
+ : undefined,
497
+ publicKeyPath,
498
+ keyType: sshPublicKey.split(/\s+/)[0],
499
+ sshFingerprint: sshFingerprint(sshPublicKey),
500
+ generated: false,
501
+ };
502
+ await store.saveIdentity(identity);
503
+ return { identity, sshPublicKey };
504
+ }
505
+
506
+ async function resolveServerIdentity(store, serverId, explicit) {
507
+ if (explicit) return resolve(explicit);
508
+ const identity = await store.identityForServer(serverId);
509
+ if (!identity?.privateKeyPath) {
510
+ throw new CliError(
511
+ `No private SSH identity is mapped to ${serverId}. Use --identity once or run warpmetal server identity attach.`,
512
+ { exitCode: 4, details: { code: "identity_required", serverId } },
513
+ );
514
+ }
515
+ return identity.privateKeyPath;
516
+ }
517
+
432
518
  async function handlePrepareOrder(client, store, options, context) {
433
519
  const planId = stringOption(options, "plan", { required: true });
434
520
  const hostname = stringOption(options, "hostname", { required: true });
435
521
  const osName = stringOption(options, "os", { required: true });
436
- const publicKeyFile = stringOption(options, "ssh-public-key-file", {
437
- required: true,
438
- });
522
+ const publicKeyFile = stringOption(options, "ssh-public-key-file");
523
+ const generateSshKey = booleanOption(options, "generate-ssh-key");
524
+ if (Boolean(publicKeyFile) === generateSshKey) {
525
+ throw new CliError(
526
+ "Use exactly one of --generate-ssh-key or --ssh-public-key-file.",
527
+ { exitCode: 2 },
528
+ );
529
+ }
439
530
  const email = stringOption(options, "email");
440
531
  const runtimeFile = stringOption(options, "runtime-file");
441
532
  const sandboxes = runtimeFile
@@ -470,15 +561,32 @@ async function handlePrepareOrder(client, store, options, context) {
470
561
  }
471
562
  if (sandboxes) validateRuntimeCatalog(product, osName, sandboxes);
472
563
 
473
- const sshPublicKey = await readSshPublicKey(publicKeyFile);
474
- const request = { planId, hostname, osName, sshPublicKey };
564
+ const selected = generateSshKey
565
+ ? await generatedIdentity(store, hostname, options, context)
566
+ : await existingIdentity(
567
+ store,
568
+ hostname,
569
+ publicKeyFile,
570
+ stringOption(options, "ssh-key-name"),
571
+ );
572
+ const request = {
573
+ planId,
574
+ hostname,
575
+ osName,
576
+ sshPublicKey: selected.sshPublicKey,
577
+ sshKeyLabel: selected.identity.keyName,
578
+ };
475
579
  if (email) request.email = email;
476
580
  if (sandboxes) request.agentRuntime = { sandboxes };
477
581
  const key =
478
582
  stringOption(options, "idempotency-key") || idempotencyKey("order");
479
583
  const response = await client.prepareOrder(request, key);
480
584
  const checkoutBody = JSON.stringify({ taskId: response.data.task.id });
481
- await store.savePreparedOrder(response.data, checkoutBody);
585
+ await store.savePreparedOrder(
586
+ response.data,
587
+ checkoutBody,
588
+ selected.identity.identityId,
589
+ );
482
590
  const safe = safePreparedOrder(response.data, store.path);
483
591
  if (sandboxes) {
484
592
  safe.agentRuntimeIntent = {
@@ -650,6 +758,518 @@ async function handleCheckoutSubmit(client, store, options, context) {
650
758
  }
651
759
  }
652
760
 
761
+ function atomicOption(options, name, { required = false } = {}) {
762
+ const value = stringOption(options, name, { required });
763
+ if (value === undefined) return undefined;
764
+ if (!/^(?:0|[1-9][0-9]{0,77})$/.test(value) || value === "0") {
765
+ throw new CliError(`--${name} must be a positive canonical atomic amount.`, {
766
+ exitCode: 2,
767
+ });
768
+ }
769
+ return value;
770
+ }
771
+
772
+ export function refillRenewBy(termEndsAt, currentTime = Date.now()) {
773
+ const minimum = currentTime + 30 * 60 * 1000;
774
+ const parsed = Date.parse(termEndsAt || "");
775
+ return new Date(Number.isFinite(parsed) && parsed > minimum ? parsed : minimum).toISOString();
776
+ }
777
+
778
+ async function handleRenewalConfigure(client, store, options, context) {
779
+ const serverId = stringOption(options, "server", { required: true });
780
+ const wallet = stringOption(options, "wallet", { required: true });
781
+ const maximumRenewals = integerOption(options, "maximum-renewals");
782
+ const renewThrough = stringOption(options, "renew-through");
783
+ if (Boolean(maximumRenewals) === Boolean(renewThrough)) {
784
+ throw new CliError(
785
+ "Use exactly one of --maximum-renewals or --renew-through.",
786
+ { exitCode: 2 },
787
+ );
788
+ }
789
+ const maximumPaymentAtomic = atomicOption(
790
+ options,
791
+ "maximum-payment-atomic",
792
+ { required: true },
793
+ );
794
+ const maximumTotalSpendAtomic = atomicOption(
795
+ options,
796
+ "maximum-total-spend-atomic",
797
+ );
798
+ const refillTargetAtomic =
799
+ atomicOption(options, "refill-target-atomic") || maximumPaymentAtomic;
800
+ if (BigInt(refillTargetAtomic) > BigInt(maximumPaymentAtomic)) {
801
+ throw new CliError(
802
+ "--refill-target-atomic cannot exceed --maximum-payment-atomic.",
803
+ { exitCode: 2 },
804
+ );
805
+ }
806
+ const body = {
807
+ enabled: !booleanOption(options, "disabled"),
808
+ renewBeforeDays: integerOption(options, "renew-before-days", 3),
809
+ maximumPaymentAtomic,
810
+ maximumRenewals,
811
+ renewThrough,
812
+ maximumTotalSpendAtomic,
813
+ allowedNetwork: stringOption(options, "allowed-network", {
814
+ required: true,
815
+ }),
816
+ allowedAsset: stringOption(options, "allowed-asset", { required: true }),
817
+ };
818
+ for (const [key, value] of Object.entries(body)) {
819
+ if (value === undefined) delete body[key];
820
+ }
821
+ const token = await requireServerToken(store, serverId, options, context.env);
822
+ const result = await client.putRenewalPolicy(serverId, body, token);
823
+ await store.saveRenewalPolicy(
824
+ serverId,
825
+ result.data.policy,
826
+ wallet,
827
+ refillTargetAtomic,
828
+ );
829
+ const email = stringOption(options, "email");
830
+ let notifications;
831
+ if (email) {
832
+ notifications = (
833
+ await client.putNotifications(serverId, { email }, token)
834
+ ).data;
835
+ }
836
+ const output = { ...result.data, wallet, refillTargetAtomic, notifications };
837
+ emit(
838
+ context.stdout,
839
+ output,
840
+ context.json,
841
+ `Configured bounded renewal for ${serverId} with wallet ${wallet}.${email ? " Check the email verification link." : ""}`,
842
+ );
843
+ return 0;
844
+ }
845
+
846
+ async function handleRenewalStatus(client, store, options, context) {
847
+ const serverId = stringOption(options, "server", { required: true });
848
+ const token = await requireServerToken(store, serverId, options, context.env);
849
+ const result = await client.getRenewalPolicy(serverId, token);
850
+ const local = await store.renewal(serverId);
851
+ const output = { ...result.data, local: local ? { wallet: local.wallet, refillTargetAtomic: local.refillTargetAtomic } : null };
852
+ emit(
853
+ context.stdout,
854
+ output,
855
+ context.json,
856
+ result.data.configured
857
+ ? `${serverId}: ${result.data.policy.nextAction}${result.data.policy.reason ? ` (${result.data.policy.reason})` : ""}`
858
+ : `${serverId}: no renewal policy configured`,
859
+ );
860
+ return result.data.policy?.nextAction === "approval_required" ? 6 : 0;
861
+ }
862
+
863
+ async function attachRenewalPaymentWorkflow(
864
+ client,
865
+ store,
866
+ serverId,
867
+ server,
868
+ renewal,
869
+ response,
870
+ requestedEnvelopePath,
871
+ ) {
872
+ const bodyText = JSON.stringify({ serverId });
873
+ const checkoutPath = `/checkout/${server.planId}/renew`;
874
+ const safe = challengeResult(serverId, bodyText, response);
875
+ if (!safe.paymentRequired) return safe;
876
+ const request = createPaymentRequestEnvelope({
877
+ baseUrl: client.baseUrl,
878
+ checkoutPath,
879
+ checkoutBody: bodyText,
880
+ paymentRequired: safe.paymentRequired,
881
+ merchantReference: `renew:${serverId}:${server.termEndsAt || "unset"}`,
882
+ });
883
+ const matchingTerms = request.terms.filter(
884
+ (term) =>
885
+ term.agentWalletSupported &&
886
+ term.network === renewal.policy.allowedNetwork &&
887
+ (term.asset.startsWith("0x")
888
+ ? term.asset.toLowerCase() === renewal.policy.allowedAsset.toLowerCase()
889
+ : term.asset === renewal.policy.allowedAsset),
890
+ );
891
+ if (matchingTerms.length !== 1) {
892
+ throw new CliError(
893
+ "The renewal challenge does not contain exactly one payment rail allowed by policy.",
894
+ { exitCode: 6 },
895
+ );
896
+ }
897
+ const term = matchingTerms[0];
898
+ if (BigInt(term.amountAtomic) > BigInt(renewal.policy.maximumPaymentAtomic)) {
899
+ throw new CliError("The renewal price is above the autonomous policy ceiling.", {
900
+ exitCode: 6,
901
+ details: { code: "renewal_price_above_limit", term },
902
+ });
903
+ }
904
+ if (
905
+ renewal.policy.maximumTotalSpendAtomic &&
906
+ BigInt(renewal.policy.totalSpendAtomic) + BigInt(term.amountAtomic) >
907
+ BigInt(renewal.policy.maximumTotalSpendAtomic)
908
+ ) {
909
+ throw new CliError("The renewal would exceed the cumulative policy budget.", {
910
+ exitCode: 6,
911
+ details: { code: "renewal_budget_exhausted", term },
912
+ });
913
+ }
914
+ const defaults = defaultPaymentPaths(
915
+ store.directory,
916
+ `renew-${serverId}`,
917
+ request.challengeDigest,
918
+ );
919
+ const requestEnvelopePath = await writePaymentRequestEnvelope(
920
+ requestedEnvelopePath || defaults.requestEnvelopePath,
921
+ request.envelope,
922
+ );
923
+ const workflow = paymentWorkflow({
924
+ taskId: server.id,
925
+ serverId,
926
+ kind: "renewal",
927
+ requestEnvelopePath,
928
+ paymentArtifactPath: defaults.paymentArtifactPath,
929
+ });
930
+ const refillTarget =
931
+ renewal.refillTargetAtomic &&
932
+ BigInt(renewal.refillTargetAtomic) >= BigInt(term.amountAtomic)
933
+ ? renewal.refillTargetAtomic
934
+ : term.amountAtomic;
935
+ const refillWorkflow = {
936
+ environment: {
937
+ X402API_NOTIFICATION_URL: `${client.baseUrl}/notifications/x402api/refill`,
938
+ },
939
+ argv: [
940
+ "x402api",
941
+ "wallet",
942
+ "notify-refill",
943
+ "--wallet",
944
+ renewal.wallet,
945
+ "--subscription-reference",
946
+ renewal.policy.notificationReference,
947
+ "--renew-by",
948
+ refillRenewBy(server.termEndsAt),
949
+ "--target-balance-atomic",
950
+ refillTarget,
951
+ "--reason",
952
+ "renewal",
953
+ "--json",
954
+ ],
955
+ };
956
+ Object.assign(safe, {
957
+ serverId,
958
+ paymentTerms: request.terms,
959
+ selectedPolicyTerm: term,
960
+ paymentRequestDigest: request.requestDigest,
961
+ paymentChallengeDigest: request.challengeDigest,
962
+ paymentWorkflow: workflow,
963
+ refillWorkflow,
964
+ });
965
+ await store.saveRenewalChallenge(serverId, {
966
+ checkoutPath,
967
+ checkoutBody: bodyText,
968
+ termEndsAt: server.termEndsAt,
969
+ paymentRequired: safe.paymentRequired,
970
+ paymentAttemptId: safe.paymentAttemptId,
971
+ paymentRequestDigest: safe.paymentRequestDigest,
972
+ paymentChallengeDigest: safe.paymentChallengeDigest,
973
+ paymentRequestEnvelopePath: requestEnvelopePath,
974
+ paymentArtifactPath: defaults.paymentArtifactPath,
975
+ selectedPolicyTerm: term,
976
+ });
977
+ return safe;
978
+ }
979
+
980
+ function renewalActionExitCode(action) {
981
+ if (action === "sign_payment") return 7;
982
+ if (action === "reconcile_pending") return 8;
983
+ if (["policy_required", "approval_required", "manual_review"].includes(action)) {
984
+ return 6;
985
+ }
986
+ return 0;
987
+ }
988
+
989
+ async function prepareRenewal(client, store, serverId, options, context) {
990
+ const token = await requireServerToken(store, serverId, options, context.env);
991
+ const [serverResult, policyResult] = await Promise.all([
992
+ client.getServer(serverId, token),
993
+ client.getRenewalPolicy(serverId, token),
994
+ ]);
995
+ if (!policyResult.data.configured) {
996
+ return {
997
+ action: "policy_required",
998
+ serverId,
999
+ reason: "renewal_policy_required",
1000
+ task: serverResult.data.task,
1001
+ };
1002
+ }
1003
+ const policy = policyResult.data.policy;
1004
+ if (policy.nextAction !== "sign_payment") {
1005
+ return {
1006
+ action: policy.nextAction,
1007
+ serverId,
1008
+ reason: policy.reason,
1009
+ policy,
1010
+ task: serverResult.data.task,
1011
+ };
1012
+ }
1013
+ const local = await store.renewal(serverId);
1014
+ if (!local?.wallet) {
1015
+ return {
1016
+ action: "approval_required",
1017
+ serverId,
1018
+ reason: "wallet_binding_required",
1019
+ policy,
1020
+ task: serverResult.data.task,
1021
+ };
1022
+ }
1023
+ local.policy = policy;
1024
+ const bodyText = JSON.stringify({ serverId });
1025
+ const response = await client.renewalCheckout(serverResult.data.task.planId, {
1026
+ bodyText,
1027
+ token,
1028
+ });
1029
+ const safe = await attachRenewalPaymentWorkflow(
1030
+ client,
1031
+ store,
1032
+ serverId,
1033
+ serverResult.data.task,
1034
+ local,
1035
+ response,
1036
+ stringOption(options, "request-envelope-out"),
1037
+ );
1038
+ safe.action =
1039
+ response.status === 402
1040
+ ? "sign_payment"
1041
+ : response.status === 409
1042
+ ? "manual_review"
1043
+ : response.status === 202
1044
+ ? "reconcile_pending"
1045
+ : safe.status === "renewed"
1046
+ ? "renewed"
1047
+ : "reconcile_pending";
1048
+ safe.policy = policy;
1049
+ safe.task = response.data?.task || serverResult.data.task;
1050
+ return safe;
1051
+ }
1052
+
1053
+ async function handleRenewalPrepare(client, store, options, context) {
1054
+ const serverId = stringOption(options, "server", { required: true });
1055
+ const safe = await prepareRenewal(client, store, serverId, options, context);
1056
+ const instructions = safe.paymentWorkflow
1057
+ ? `\nAuthorize: ${shellCommand(safe.paymentWorkflow.authorize.argv)}\nIf the wallet is short of funds, set ${Object.entries(safe.refillWorkflow.environment).map(([key, value]) => `${key}=${value}`).join(" ")} and run: ${shellCommand(safe.refillWorkflow.argv)}\nSubmit: ${shellCommand(safe.paymentWorkflow.submit.argv)}`
1058
+ : "";
1059
+ emit(
1060
+ context.stdout,
1061
+ safe,
1062
+ context.json,
1063
+ safe.action === "sign_payment"
1064
+ ? `Renewal payment authorization is required for ${serverId}.${instructions}`
1065
+ : `${serverId}: ${safe.action}${safe.reason ? ` (${safe.reason})` : ""}`,
1066
+ );
1067
+ return renewalActionExitCode(safe.action);
1068
+ }
1069
+
1070
+ async function handleRenewalRun(client, store, options, context) {
1071
+ const explicit = stringOption(options, "server");
1072
+ const allDue = booleanOption(options, "all-due");
1073
+ if (Boolean(explicit) === allDue) {
1074
+ throw new CliError("Use exactly one of --server or --all-due.", { exitCode: 2 });
1075
+ }
1076
+ if (allDue && stringOption(options, "request-envelope-out")) {
1077
+ throw new CliError("--request-envelope-out cannot be shared by --all-due.", {
1078
+ exitCode: 2,
1079
+ });
1080
+ }
1081
+ if (explicit) return handleRenewalPrepare(client, store, options, context);
1082
+
1083
+ const serverIds = (await store.summary()).renewals.map(
1084
+ (renewal) => renewal.serverId,
1085
+ );
1086
+ const results = [];
1087
+ for (const serverId of serverIds) {
1088
+ try {
1089
+ results.push(await prepareRenewal(client, store, serverId, options, context));
1090
+ } catch (error) {
1091
+ results.push({
1092
+ action: "approval_required",
1093
+ serverId,
1094
+ reason: "renewal_check_failed",
1095
+ error: toErrorMessage(error),
1096
+ });
1097
+ }
1098
+ }
1099
+ const output = { action: "batch", results };
1100
+ emit(
1101
+ context.stdout,
1102
+ output,
1103
+ context.json,
1104
+ results.length
1105
+ ? results.map((result) => `${result.serverId}: ${result.action}`).join("\n")
1106
+ : "No local renewal policies are configured.",
1107
+ );
1108
+ const codes = results.map((result) => renewalActionExitCode(result.action));
1109
+ return codes.includes(6) ? 6 : codes.includes(8) ? 8 : codes.includes(7) ? 7 : 0;
1110
+ }
1111
+
1112
+ async function handleRenewalSubmit(client, store, options, context) {
1113
+ const serverId = stringOption(options, "server", { required: true });
1114
+ const artifactFile = stringOption(options, "payment-artifact", {
1115
+ required: true,
1116
+ });
1117
+ const renewal = await store.renewal(serverId);
1118
+ if (
1119
+ !renewal?.checkoutPath ||
1120
+ !renewal?.checkoutBody ||
1121
+ !renewal?.paymentRequired ||
1122
+ !renewal?.policy
1123
+ ) {
1124
+ throw new CliError(
1125
+ `No exact saved renewal challenge exists for ${serverId}. Run renewal prepare first.`,
1126
+ { exitCode: 2 },
1127
+ );
1128
+ }
1129
+ const expected = createPaymentRequestEnvelope({
1130
+ baseUrl: client.baseUrl,
1131
+ checkoutPath: renewal.checkoutPath,
1132
+ checkoutBody: renewal.checkoutBody,
1133
+ paymentRequired: renewal.paymentRequired,
1134
+ merchantReference: `renew:${serverId}:${renewal.termEndsAt || "unset"}`,
1135
+ });
1136
+ const walletPayment = await readPaymentArtifact(artifactFile, expected);
1137
+ const selected = expected.terms.find(
1138
+ (term) => term.requirementDigest === walletPayment.selectedRequirementDigest,
1139
+ );
1140
+ const policy = renewal.policy;
1141
+ const normalizedAsset = selected?.asset?.startsWith("0x")
1142
+ ? selected.asset.toLowerCase()
1143
+ : selected?.asset;
1144
+ if (
1145
+ !selected ||
1146
+ selected.network !== policy.allowedNetwork ||
1147
+ normalizedAsset !==
1148
+ (policy.allowedAsset.startsWith("0x")
1149
+ ? policy.allowedAsset.toLowerCase()
1150
+ : policy.allowedAsset) ||
1151
+ BigInt(selected.amountAtomic) > BigInt(policy.maximumPaymentAtomic)
1152
+ ) {
1153
+ throw new CliError(
1154
+ "The payment artifact selected a term outside the autonomous renewal policy.",
1155
+ { exitCode: 6 },
1156
+ );
1157
+ }
1158
+ await store.saveRenewalPaymentAttempt(serverId, walletPayment);
1159
+ const token = await requireServerToken(store, serverId, options, context.env);
1160
+ const deadline = timeoutDeadline(
1161
+ integerOption(options, "timeout-seconds", 900),
1162
+ );
1163
+ while (true) {
1164
+ const response = await client.renewalCheckout(
1165
+ (await client.getServer(serverId, token)).data.task.planId,
1166
+ {
1167
+ bodyText: renewal.checkoutBody,
1168
+ token,
1169
+ paymentSignature: walletPayment.paymentSignature,
1170
+ },
1171
+ );
1172
+ const retryable =
1173
+ response.status === 202 &&
1174
+ ["payment_pending", "payment_finalizing"].includes(response.data?.status);
1175
+ if (booleanOption(options, "wait") && retryable) {
1176
+ ensureBeforeDeadline(deadline, `Renewal ${serverId}`);
1177
+ await delay(suggestedDelay(response));
1178
+ continue;
1179
+ }
1180
+ const output = {
1181
+ status: response.data?.status,
1182
+ serverId,
1183
+ task: response.data?.task,
1184
+ paymentAttemptId:
1185
+ response.data?.paymentAttemptId ||
1186
+ response.headers["x-warpmetal-payment-attempt"],
1187
+ walletPayment: {
1188
+ attemptId: walletPayment.attemptId,
1189
+ wallet: walletPayment.wallet,
1190
+ payerAddress: walletPayment.payerAddress,
1191
+ artifactPath: walletPayment.path,
1192
+ },
1193
+ };
1194
+ emit(
1195
+ context.stdout,
1196
+ output,
1197
+ context.json,
1198
+ `Renewal status for ${serverId}: ${output.status}`,
1199
+ );
1200
+ if (response.status === 409) return 6;
1201
+ if (response.status === 402) return 7;
1202
+ if (retryable) return 8;
1203
+ return 0;
1204
+ }
1205
+ }
1206
+
1207
+ async function handleRenewalDue(client, store, options, context) {
1208
+ const explicit = stringOption(options, "server");
1209
+ const all = booleanOption(options, "all");
1210
+ if (Boolean(explicit) === all) {
1211
+ throw new CliError("Use exactly one of --server or --all.", { exitCode: 2 });
1212
+ }
1213
+ const serverIds = explicit
1214
+ ? [explicit]
1215
+ : (await store.summary()).renewals.map((renewal) => renewal.serverId);
1216
+ const results = [];
1217
+ for (const serverId of serverIds) {
1218
+ try {
1219
+ const token = await requireServerToken(store, serverId, options, context.env);
1220
+ const result = await client.getRenewalPolicy(serverId, token);
1221
+ results.push({ serverId, ...result.data });
1222
+ } catch (error) {
1223
+ results.push({ serverId, error: toErrorMessage(error) });
1224
+ }
1225
+ }
1226
+ const due = results.filter(
1227
+ (result) =>
1228
+ result.policy?.nextAction && result.policy.nextAction !== "not_due",
1229
+ );
1230
+ emit(
1231
+ context.stdout,
1232
+ { due, checked: results },
1233
+ context.json,
1234
+ due.length
1235
+ ? due.map((result) => `${result.serverId}: ${result.policy.nextAction}`).join("\n")
1236
+ : "No locally configured server is due for renewal.",
1237
+ );
1238
+ return due.length ? 8 : 0;
1239
+ }
1240
+
1241
+ async function handleNotificationsConfigure(client, store, options, context) {
1242
+ const serverId = stringOption(options, "server", { required: true });
1243
+ const email = stringOption(options, "email", { required: true });
1244
+ const eventValue = stringOption(options, "events");
1245
+ const body = { email };
1246
+ if (eventValue) body.events = eventValue.split(",").map((value) => value.trim());
1247
+ const token = await requireServerToken(store, serverId, options, context.env);
1248
+ const result = await client.putNotifications(serverId, body, token);
1249
+ emit(
1250
+ context.stdout,
1251
+ result.data,
1252
+ context.json,
1253
+ `Verification email queued for ${result.data.subscription.email}.`,
1254
+ );
1255
+ return 0;
1256
+ }
1257
+
1258
+ async function handleNotificationsStatus(client, store, options, context) {
1259
+ const serverId = stringOption(options, "server", { required: true });
1260
+ const token = await requireServerToken(store, serverId, options, context.env);
1261
+ const result = await client.getNotifications(serverId, token);
1262
+ emit(
1263
+ context.stdout,
1264
+ result.data,
1265
+ context.json,
1266
+ result.data.configured
1267
+ ? `${serverId}: notifications ${result.data.subscription.verified ? "verified" : "awaiting verification"}`
1268
+ : `${serverId}: notifications are not configured`,
1269
+ );
1270
+ return 0;
1271
+ }
1272
+
653
1273
  async function loginServer(client, store, serverId, identity) {
654
1274
  const challenge = (await client.issueSshChallenge(serverId)).data;
655
1275
  const signature = await signSshChallenge(challenge.payload, identity);
@@ -664,9 +1284,102 @@ async function loginServer(client, store, serverId, identity) {
664
1284
  return { challenge, token };
665
1285
  }
666
1286
 
1287
+ async function handleIdentityGenerate(store, options, context) {
1288
+ const hostname = stringOption(options, "hostname", { required: true });
1289
+ const selected = await generatedIdentity(store, hostname, options, context);
1290
+ emit(
1291
+ context.stdout,
1292
+ selected.identity,
1293
+ context.json,
1294
+ `Generated ${selected.identity.keyName} at ${selected.identity.privateKeyPath}; fingerprint ${selected.identity.sshFingerprint}.`,
1295
+ );
1296
+ return 0;
1297
+ }
1298
+
1299
+ async function handleIdentityList(store, context) {
1300
+ const identities = (await store.summary()).identities;
1301
+ emit(
1302
+ context.stdout,
1303
+ { identities },
1304
+ context.json,
1305
+ identities.length
1306
+ ? identities
1307
+ .map(
1308
+ (identity) =>
1309
+ `${identity.keyName}: ${identity.serverId || "unbound"} (${identity.sshFingerprint})`,
1310
+ )
1311
+ .join("\n")
1312
+ : "No WarpMetal SSH identities are stored.",
1313
+ );
1314
+ return 0;
1315
+ }
1316
+
1317
+ async function handleServerIdentity(store, options, context) {
1318
+ const serverId = stringOption(options, "server", { required: true });
1319
+ const identity = await store.identityForServer(serverId);
1320
+ if (!identity) {
1321
+ throw new CliError(`No SSH identity is mapped to ${serverId}.`, {
1322
+ exitCode: 4,
1323
+ details: { code: "identity_required", serverId },
1324
+ });
1325
+ }
1326
+ emit(
1327
+ context.stdout,
1328
+ { identity },
1329
+ context.json,
1330
+ `${serverId} uses ${identity.keyName} (${identity.sshFingerprint}) at ${identity.privateKeyPath || "private key unavailable"}.`,
1331
+ );
1332
+ return 0;
1333
+ }
1334
+
1335
+ async function handleServerIdentityAttach(client, store, options, context) {
1336
+ const serverId = stringOption(options, "server", { required: true });
1337
+ const privateKeyPath = resolve(
1338
+ stringOption(options, "identity", { required: true }),
1339
+ );
1340
+ const publicKeyPath = `${privateKeyPath}.pub`;
1341
+ const publicKey = await readSshPublicKey(publicKeyPath);
1342
+ const fingerprint = sshFingerprint(publicKey);
1343
+ const token = await requireServerToken(store, serverId, options, context.env);
1344
+ const server = (await client.getServer(serverId, token)).data?.task;
1345
+ if (!server || server.sshFingerprint !== fingerprint) {
1346
+ throw new CliError(
1347
+ "The local public key fingerprint does not match the server owner key.",
1348
+ { exitCode: 4 },
1349
+ );
1350
+ }
1351
+ const keyName = serverKeyName(
1352
+ server.hostname,
1353
+ stringOption(options, "ssh-key-name") || basename(privateKeyPath),
1354
+ ).keyName;
1355
+ const identity = {
1356
+ identityId: identityId(),
1357
+ keyName,
1358
+ hostnameAtCreation: server.hostname,
1359
+ privateKeyPath,
1360
+ publicKeyPath,
1361
+ keyType: publicKey.split(/\s+/)[0],
1362
+ sshFingerprint: fingerprint,
1363
+ generated: false,
1364
+ };
1365
+ await store.saveIdentity(identity);
1366
+ await store.bindIdentity(identity.identityId, server.taskId || server.id, serverId);
1367
+ emit(
1368
+ context.stdout,
1369
+ { identity: await store.identity(identity.identityId) },
1370
+ context.json,
1371
+ `Mapped ${keyName} to ${serverId}.`,
1372
+ );
1373
+ return 0;
1374
+ }
1375
+
667
1376
  async function handleServerLogin(client, store, options, context) {
668
1377
  const serverId = stringOption(options, "server", { required: true });
669
- const identity = stringOption(options, "identity", { required: true });
1378
+ const identity = await resolveServerIdentity(
1379
+ store,
1380
+ serverId,
1381
+ stringOption(options, "identity"),
1382
+ );
670
1383
  const { challenge, token } = await loginServer(
671
1384
  client,
672
1385
  store,
@@ -741,8 +1454,16 @@ async function handleServerPower(client, store, options, context) {
741
1454
  return result.data.operation.state === "manual_review" ? 6 : 0;
742
1455
  }
743
1456
 
744
- async function applyReloadResult(store, serverId, operation) {
1457
+ async function applyReloadResult(
1458
+ store,
1459
+ serverId,
1460
+ operation,
1461
+ pendingIdentityId,
1462
+ ) {
745
1463
  if (operation?.state !== "succeeded") return;
1464
+ if (pendingIdentityId) {
1465
+ await store.bindIdentity(pendingIdentityId, undefined, serverId);
1466
+ }
746
1467
  await store.invalidateServerAccess(serverId);
747
1468
  if (operation.result?.reloadImpact?.agentRuntimeAffected) {
748
1469
  await store.saveRuntime(serverId, {
@@ -788,9 +1509,37 @@ async function handleServerReload(client, store, options, context) {
788
1509
  const hostname = stringOption(options, "hostname");
789
1510
  const osName = stringOption(options, "os");
790
1511
  const publicKeyFile = stringOption(options, "ssh-public-key-file");
1512
+ const generateSshKey = booleanOption(options, "generate-ssh-key");
1513
+ if (publicKeyFile && generateSshKey) {
1514
+ throw new CliError(
1515
+ "Use at most one of --generate-ssh-key or --ssh-public-key-file during reload.",
1516
+ { exitCode: 2 },
1517
+ );
1518
+ }
791
1519
  if (hostname) body.hostname = hostname;
792
1520
  if (osName) body.osName = osName;
793
- if (publicKeyFile) body.sshPublicKey = await readSshPublicKey(publicKeyFile);
1521
+ let replacementIdentity;
1522
+ if (generateSshKey) {
1523
+ const selected = await generatedIdentity(
1524
+ store,
1525
+ hostname || server.hostname,
1526
+ options,
1527
+ context,
1528
+ );
1529
+ replacementIdentity = selected.identity;
1530
+ body.sshPublicKey = selected.sshPublicKey;
1531
+ body.sshKeyLabel = replacementIdentity.keyName;
1532
+ } else if (publicKeyFile) {
1533
+ const selected = await existingIdentity(
1534
+ store,
1535
+ hostname || server.hostname,
1536
+ publicKeyFile,
1537
+ stringOption(options, "ssh-key-name"),
1538
+ );
1539
+ replacementIdentity = selected.identity;
1540
+ body.sshPublicKey = selected.sshPublicKey;
1541
+ body.sshKeyLabel = replacementIdentity.keyName;
1542
+ }
794
1543
 
795
1544
  const key =
796
1545
  stringOption(options, "idempotency-key") || idempotencyKey("reload");
@@ -799,7 +1548,9 @@ async function handleServerReload(client, store, options, context) {
799
1548
  if (!operationId) {
800
1549
  throw new CliError("WarpMetal did not return a reload operation ID.");
801
1550
  }
802
- await store.saveOperation(operationId, serverId, "reload");
1551
+ await store.saveOperation(operationId, serverId, "reload", {
1552
+ pendingIdentityId: replacementIdentity?.identityId,
1553
+ });
803
1554
  if (booleanOption(options, "wait")) {
804
1555
  result = await pollOperation(
805
1556
  client,
@@ -810,7 +1561,12 @@ async function handleServerReload(client, store, options, context) {
810
1561
  }
811
1562
 
812
1563
  const operation = result.data?.operation;
813
- await applyReloadResult(store, serverId, operation);
1564
+ await applyReloadResult(
1565
+ store,
1566
+ serverId,
1567
+ operation,
1568
+ replacementIdentity?.identityId,
1569
+ );
814
1570
  emit(
815
1571
  context.stdout,
816
1572
  result.data,
@@ -853,7 +1609,12 @@ async function handleOperationGet(client, store, options, context) {
853
1609
  )
854
1610
  : await client.getOperation(operationId, token);
855
1611
  if (saved?.kind === "reload") {
856
- await applyReloadResult(store, serverId, result.data?.operation);
1612
+ await applyReloadResult(
1613
+ store,
1614
+ serverId,
1615
+ result.data?.operation,
1616
+ saved.pendingIdentityId,
1617
+ );
857
1618
  }
858
1619
  emit(
859
1620
  context.stdout,
@@ -904,7 +1665,11 @@ async function handleRuntimeGet(client, store, options, context) {
904
1665
 
905
1666
  async function handleRuntimeInstall(client, store, options, context) {
906
1667
  const serverId = stringOption(options, "server", { required: true });
907
- const identity = stringOption(options, "identity", { required: true });
1668
+ const identity = await resolveServerIdentity(
1669
+ store,
1670
+ serverId,
1671
+ stringOption(options, "identity"),
1672
+ );
908
1673
  const sshUser = stringOption(options, "ssh-user", { required: true });
909
1674
  if (stringOption(options, "confirm", { required: true }) !== "INSTALL") {
910
1675
  throw new CliError(
@@ -1487,6 +2252,8 @@ async function dispatch(positionals, options, passthrough, context) {
1487
2252
  "hostname",
1488
2253
  "os",
1489
2254
  "ssh-public-key-file",
2255
+ "generate-ssh-key",
2256
+ "ssh-key-name",
1490
2257
  "email",
1491
2258
  "runtime-file",
1492
2259
  "confirm",
@@ -1521,6 +2288,104 @@ async function dispatch(positionals, options, passthrough, context) {
1521
2288
  "timeout-seconds",
1522
2289
  ]);
1523
2290
  return handleCheckoutSubmit(client, store, options, context);
2291
+ case "identity generate":
2292
+ rejectUnknownOptions(options, [
2293
+ ...COMMON_OPTIONS,
2294
+ "hostname",
2295
+ "ssh-key-name",
2296
+ ]);
2297
+ return handleIdentityGenerate(store, options, context);
2298
+ case "identity list":
2299
+ rejectUnknownOptions(options, COMMON_OPTIONS);
2300
+ return handleIdentityList(store, context);
2301
+ case "server identity":
2302
+ rejectUnknownOptions(options, [...COMMON_OPTIONS, "server"]);
2303
+ return handleServerIdentity(store, options, context);
2304
+ case "server identity attach":
2305
+ rejectUnknownOptions(options, [
2306
+ ...COMMON_OPTIONS,
2307
+ "server",
2308
+ "identity",
2309
+ "ssh-key-name",
2310
+ "token-file",
2311
+ ]);
2312
+ return handleServerIdentityAttach(client, store, options, context);
2313
+ case "renewal configure":
2314
+ rejectUnknownOptions(options, [
2315
+ ...COMMON_OPTIONS,
2316
+ "server",
2317
+ "token-file",
2318
+ "wallet",
2319
+ "renew-before-days",
2320
+ "maximum-payment-atomic",
2321
+ "maximum-renewals",
2322
+ "renew-through",
2323
+ "maximum-total-spend-atomic",
2324
+ "allowed-network",
2325
+ "allowed-asset",
2326
+ "refill-target-atomic",
2327
+ "email",
2328
+ "disabled",
2329
+ ]);
2330
+ return handleRenewalConfigure(client, store, options, context);
2331
+ case "renewal status":
2332
+ rejectUnknownOptions(options, [
2333
+ ...COMMON_OPTIONS,
2334
+ "server",
2335
+ "token-file",
2336
+ ]);
2337
+ return handleRenewalStatus(client, store, options, context);
2338
+ case "renewal due":
2339
+ rejectUnknownOptions(options, [
2340
+ ...COMMON_OPTIONS,
2341
+ "server",
2342
+ "all",
2343
+ "token-file",
2344
+ ]);
2345
+ return handleRenewalDue(client, store, options, context);
2346
+ case "renewal prepare":
2347
+ rejectUnknownOptions(options, [
2348
+ ...COMMON_OPTIONS,
2349
+ "server",
2350
+ "token-file",
2351
+ "request-envelope-out",
2352
+ ]);
2353
+ return handleRenewalPrepare(client, store, options, context);
2354
+ case "renewal submit":
2355
+ rejectUnknownOptions(options, [
2356
+ ...COMMON_OPTIONS,
2357
+ "server",
2358
+ "token-file",
2359
+ "payment-artifact",
2360
+ "wait",
2361
+ "timeout-seconds",
2362
+ ]);
2363
+ return handleRenewalSubmit(client, store, options, context);
2364
+ case "renewal run":
2365
+ rejectUnknownOptions(options, [
2366
+ ...COMMON_OPTIONS,
2367
+ "server",
2368
+ "all-due",
2369
+ "token-file",
2370
+ "request-envelope-out",
2371
+ ]);
2372
+ return handleRenewalRun(client, store, options, context);
2373
+ case "notifications configure":
2374
+ rejectUnknownOptions(options, [
2375
+ ...COMMON_OPTIONS,
2376
+ "server",
2377
+ "token-file",
2378
+ "email",
2379
+ "events",
2380
+ ]);
2381
+ return handleNotificationsConfigure(client, store, options, context);
2382
+ case "notifications status":
2383
+ rejectUnknownOptions(options, [
2384
+ ...COMMON_OPTIONS,
2385
+ "server",
2386
+ "token-file",
2387
+ ]);
2388
+ return handleNotificationsStatus(client, store, options, context);
1524
2389
  case "server login":
1525
2390
  rejectUnknownOptions(options, [...COMMON_OPTIONS, "server", "identity"]);
1526
2391
  return handleServerLogin(client, store, options, context);
@@ -1554,6 +2419,8 @@ async function dispatch(positionals, options, passthrough, context) {
1554
2419
  "hostname",
1555
2420
  "os",
1556
2421
  "ssh-public-key-file",
2422
+ "generate-ssh-key",
2423
+ "ssh-key-name",
1557
2424
  "idempotency-key",
1558
2425
  "wait",
1559
2426
  "timeout-seconds",
@@ -1728,7 +2595,7 @@ async function dispatch(positionals, options, passthrough, context) {
1728
2595
  context.stdout,
1729
2596
  summary,
1730
2597
  context.json,
1731
- `State: ${summary.stateFile}\nOrders: ${summary.orders.length}\nServers: ${summary.servers.length}\nOperations: ${summary.operations.length}\nRuntimes: ${summary.runtimes.length}\nSandboxes: ${summary.sandboxes.length}\nAccess grants: ${summary.accessGrants.length}`,
2598
+ `State: ${summary.stateFile}\nOrders: ${summary.orders.length}\nServers: ${summary.servers.length}\nSSH identities: ${summary.identities.length}\nRenewal policies: ${summary.renewals.length}\nOperations: ${summary.operations.length}\nRuntimes: ${summary.runtimes.length}\nSandboxes: ${summary.sandboxes.length}\nAccess grants: ${summary.accessGrants.length}`,
1732
2599
  );
1733
2600
  return 0;
1734
2601
  }