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/README.md +60 -9
- package/package.json +5 -3
- package/skills/warpmetal/SKILL.md +42 -6
- package/skills/warpmetal/agents/openai.yaml +2 -2
- package/skills/warpmetal/references/cli-reference.md +48 -4
- package/skills/warpmetal/references/payments.md +16 -2
- package/skills/warpmetal/references/renewals.md +127 -0
- package/skills/warpmetal/references/safety.md +14 -0
- package/src/api.js +70 -22
- package/src/cli.js +887 -20
- package/src/payment.js +26 -11
- package/src/ssh.js +111 -2
- package/src/state.js +155 -4
package/src/payment.js
CHANGED
|
@@ -694,6 +694,8 @@ export async function readPaymentArtifact(path, expected) {
|
|
|
694
694
|
|
|
695
695
|
export function paymentWorkflow({
|
|
696
696
|
taskId,
|
|
697
|
+
serverId,
|
|
698
|
+
kind = "checkout",
|
|
697
699
|
requestEnvelopePath,
|
|
698
700
|
paymentArtifactPath,
|
|
699
701
|
}) {
|
|
@@ -746,17 +748,30 @@ export function paymentWorkflow({
|
|
|
746
748
|
],
|
|
747
749
|
},
|
|
748
750
|
submit: {
|
|
749
|
-
argv:
|
|
750
|
-
"
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
751
|
+
argv:
|
|
752
|
+
kind === "renewal"
|
|
753
|
+
? [
|
|
754
|
+
"warpmetal",
|
|
755
|
+
"renewal",
|
|
756
|
+
"submit",
|
|
757
|
+
"--server",
|
|
758
|
+
serverId,
|
|
759
|
+
"--payment-artifact",
|
|
760
|
+
paymentArtifactPath,
|
|
761
|
+
"--wait",
|
|
762
|
+
"--json",
|
|
763
|
+
]
|
|
764
|
+
: [
|
|
765
|
+
"warpmetal",
|
|
766
|
+
"checkout",
|
|
767
|
+
"submit",
|
|
768
|
+
"--task",
|
|
769
|
+
taskId,
|
|
770
|
+
"--payment-artifact",
|
|
771
|
+
paymentArtifactPath,
|
|
772
|
+
"--wait",
|
|
773
|
+
"--json",
|
|
774
|
+
],
|
|
760
775
|
},
|
|
761
776
|
compatibility: {
|
|
762
777
|
alternativeInput: "--payment-signature-file <path>",
|
package/src/ssh.js
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import {
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
chmod,
|
|
4
|
+
lstat,
|
|
5
|
+
mkdir,
|
|
6
|
+
mkdtemp,
|
|
7
|
+
readFile,
|
|
8
|
+
rm,
|
|
9
|
+
writeFile,
|
|
10
|
+
} from "node:fs/promises";
|
|
3
11
|
import { tmpdir } from "node:os";
|
|
4
12
|
import { join, resolve } from "node:path";
|
|
5
13
|
import { spawnSync } from "node:child_process";
|
|
@@ -92,6 +100,107 @@ async function requireMissing(path) {
|
|
|
92
100
|
}
|
|
93
101
|
}
|
|
94
102
|
|
|
103
|
+
async function isMissing(path) {
|
|
104
|
+
try {
|
|
105
|
+
await lstat(path);
|
|
106
|
+
return false;
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error?.code === "ENOENT") return true;
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function serverKeyName(hostname, requestedName) {
|
|
114
|
+
const normalizedHostname = String(hostname || "").trim().toLowerCase();
|
|
115
|
+
if (
|
|
116
|
+
!/^(?=.{1,63}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(
|
|
117
|
+
normalizedHostname,
|
|
118
|
+
)
|
|
119
|
+
) {
|
|
120
|
+
throw new CliError(
|
|
121
|
+
"--hostname must be a valid DNS label before generating an SSH identity.",
|
|
122
|
+
{ exitCode: 2 },
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
let value = String(requestedName || normalizedHostname)
|
|
126
|
+
.trim()
|
|
127
|
+
.toLowerCase();
|
|
128
|
+
if (!value.startsWith("warpmetal-")) value = `warpmetal-${value}`;
|
|
129
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,79}$/.test(value)) {
|
|
130
|
+
throw new CliError(
|
|
131
|
+
"The SSH key name must contain only lowercase letters, digits, dots, underscores, or hyphens.",
|
|
132
|
+
{ exitCode: 2 },
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
return { hostname: normalizedHostname, keyName: value };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function generateServerKey(
|
|
139
|
+
directory,
|
|
140
|
+
hostname,
|
|
141
|
+
requestedName,
|
|
142
|
+
{ spawn = spawnSync, random = randomBytes } = {},
|
|
143
|
+
) {
|
|
144
|
+
const normalized = serverKeyName(hostname, requestedName);
|
|
145
|
+
const identityDirectory = resolve(directory);
|
|
146
|
+
await mkdir(identityDirectory, { recursive: true, mode: 0o700 });
|
|
147
|
+
if (process.platform !== "win32") await chmod(identityDirectory, 0o700);
|
|
148
|
+
|
|
149
|
+
let keyName = normalized.keyName;
|
|
150
|
+
let privatePath = join(identityDirectory, keyName);
|
|
151
|
+
for (let attempt = 0; attempt < 32; attempt += 1) {
|
|
152
|
+
const publicPath = `${privatePath}.pub`;
|
|
153
|
+
if ((await isMissing(privatePath)) && (await isMissing(publicPath))) break;
|
|
154
|
+
const suffix = random(2).toString("hex");
|
|
155
|
+
keyName = `${normalized.keyName.slice(0, 75)}-${suffix}`;
|
|
156
|
+
privatePath = join(identityDirectory, keyName);
|
|
157
|
+
}
|
|
158
|
+
const publicPath = `${privatePath}.pub`;
|
|
159
|
+
await requireMissing(privatePath);
|
|
160
|
+
await requireMissing(publicPath);
|
|
161
|
+
const result = spawn(
|
|
162
|
+
"ssh-keygen",
|
|
163
|
+
[
|
|
164
|
+
"-q",
|
|
165
|
+
"-t",
|
|
166
|
+
"ed25519",
|
|
167
|
+
"-N",
|
|
168
|
+
"",
|
|
169
|
+
"-C",
|
|
170
|
+
`warpmetal:${normalized.hostname}`,
|
|
171
|
+
"-f",
|
|
172
|
+
privatePath,
|
|
173
|
+
],
|
|
174
|
+
{ stdio: ["ignore", "ignore", "pipe"], shell: false },
|
|
175
|
+
);
|
|
176
|
+
if (result.error?.code === "ENOENT") {
|
|
177
|
+
throw new CliError(
|
|
178
|
+
"ssh-keygen is required to generate a WarpMetal server identity.",
|
|
179
|
+
{ exitCode: 2 },
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
if (result.error) throw result.error;
|
|
183
|
+
if (result.status !== 0) {
|
|
184
|
+
throw new CliError("ssh-keygen could not generate the server identity.", {
|
|
185
|
+
exitCode: 4,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
if (process.platform !== "win32") {
|
|
189
|
+
await chmod(privatePath, 0o600);
|
|
190
|
+
await chmod(publicPath, 0o644);
|
|
191
|
+
}
|
|
192
|
+
const publicKey = await readSshPublicKey(publicPath);
|
|
193
|
+
return {
|
|
194
|
+
keyName,
|
|
195
|
+
hostnameAtCreation: normalized.hostname,
|
|
196
|
+
privateKeyPath: privatePath,
|
|
197
|
+
publicKeyPath: publicPath,
|
|
198
|
+
keyType: "ed25519",
|
|
199
|
+
sshFingerprint: sshFingerprint(publicKey),
|
|
200
|
+
sshPublicKey: publicKey,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
95
204
|
export function sshFingerprint(publicKey) {
|
|
96
205
|
const parts = publicKey.trim().split(/\s+/);
|
|
97
206
|
if (parts.length < 2)
|
package/src/state.js
CHANGED
|
@@ -5,7 +5,7 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
|
5
5
|
|
|
6
6
|
import { CliError } from "./errors.js";
|
|
7
7
|
|
|
8
|
-
const STATE_VERSION =
|
|
8
|
+
const STATE_VERSION = 3;
|
|
9
9
|
|
|
10
10
|
function emptyState() {
|
|
11
11
|
return {
|
|
@@ -16,6 +16,8 @@ function emptyState() {
|
|
|
16
16
|
runtimes: {},
|
|
17
17
|
sandboxes: {},
|
|
18
18
|
accessGrants: {},
|
|
19
|
+
identities: {},
|
|
20
|
+
renewals: {},
|
|
19
21
|
};
|
|
20
22
|
}
|
|
21
23
|
|
|
@@ -62,6 +64,24 @@ function migrateState(value) {
|
|
|
62
64
|
runtimes: {},
|
|
63
65
|
sandboxes: {},
|
|
64
66
|
accessGrants: {},
|
|
67
|
+
identities: {},
|
|
68
|
+
renewals: {},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (
|
|
72
|
+
value?.version === 2 &&
|
|
73
|
+
value.orders &&
|
|
74
|
+
value.servers &&
|
|
75
|
+
value.operations &&
|
|
76
|
+
value.runtimes &&
|
|
77
|
+
value.sandboxes &&
|
|
78
|
+
value.accessGrants
|
|
79
|
+
) {
|
|
80
|
+
return {
|
|
81
|
+
...value,
|
|
82
|
+
version: STATE_VERSION,
|
|
83
|
+
identities: {},
|
|
84
|
+
renewals: {},
|
|
65
85
|
};
|
|
66
86
|
}
|
|
67
87
|
return value;
|
|
@@ -80,7 +100,9 @@ function validateState(input) {
|
|
|
80
100
|
!value.operations ||
|
|
81
101
|
!value.runtimes ||
|
|
82
102
|
!value.sandboxes ||
|
|
83
|
-
!value.accessGrants
|
|
103
|
+
!value.accessGrants ||
|
|
104
|
+
!value.identities ||
|
|
105
|
+
!value.renewals
|
|
84
106
|
) {
|
|
85
107
|
throw new CliError("Incomplete WarpMetal state file.", { exitCode: 2 });
|
|
86
108
|
}
|
|
@@ -121,7 +143,7 @@ export class StateStore {
|
|
|
121
143
|
return result;
|
|
122
144
|
}
|
|
123
145
|
|
|
124
|
-
async savePreparedOrder(response, checkoutBody) {
|
|
146
|
+
async savePreparedOrder(response, checkoutBody, identityId) {
|
|
125
147
|
const { task, ownerToken } = response;
|
|
126
148
|
if (!task?.id || !task?.serverId || !ownerToken) {
|
|
127
149
|
throw new CliError("WarpMetal returned an incomplete prepared order.");
|
|
@@ -134,6 +156,7 @@ export class StateStore {
|
|
|
134
156
|
checkoutPath: task.checkoutPath,
|
|
135
157
|
checkoutBody,
|
|
136
158
|
ownerToken,
|
|
159
|
+
identityId,
|
|
137
160
|
createdAt: new Date().toISOString(),
|
|
138
161
|
};
|
|
139
162
|
state.servers[task.serverId] = {
|
|
@@ -141,8 +164,115 @@ export class StateStore {
|
|
|
141
164
|
serverId: task.serverId,
|
|
142
165
|
taskId: task.id,
|
|
143
166
|
ownerToken,
|
|
167
|
+
identityId,
|
|
168
|
+
};
|
|
169
|
+
if (identityId && state.identities[identityId]) {
|
|
170
|
+
state.identities[identityId].taskId = task.id;
|
|
171
|
+
state.identities[identityId].serverId = task.serverId;
|
|
172
|
+
state.identities[identityId].boundAt = new Date().toISOString();
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async saveIdentity(identity) {
|
|
178
|
+
if (!identity?.identityId || !identity?.keyName || !identity?.publicKeyPath) {
|
|
179
|
+
throw new CliError("The SSH identity record is incomplete.", { exitCode: 2 });
|
|
180
|
+
}
|
|
181
|
+
await this.update((state) => {
|
|
182
|
+
state.identities[identity.identityId] = {
|
|
183
|
+
...identity,
|
|
184
|
+
createdAt: identity.createdAt || new Date().toISOString(),
|
|
144
185
|
};
|
|
145
186
|
});
|
|
187
|
+
return identity;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async bindIdentity(identityId, taskId, serverId) {
|
|
191
|
+
await this.update((state) => {
|
|
192
|
+
const identity = state.identities[identityId];
|
|
193
|
+
if (!identity) {
|
|
194
|
+
throw new CliError(`No local SSH identity exists for ${identityId}.`, {
|
|
195
|
+
exitCode: 2,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
if (taskId) identity.taskId = taskId;
|
|
199
|
+
identity.serverId = serverId;
|
|
200
|
+
identity.boundAt = new Date().toISOString();
|
|
201
|
+
const server = state.servers[serverId] || { serverId };
|
|
202
|
+
server.identityId = identityId;
|
|
203
|
+
state.servers[serverId] = server;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async identity(identityId) {
|
|
208
|
+
return (await this.read()).identities[identityId];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async identityForServer(serverId) {
|
|
212
|
+
const state = await this.read();
|
|
213
|
+
const identityId = state.servers[serverId]?.identityId;
|
|
214
|
+
if (identityId && state.identities[identityId]) {
|
|
215
|
+
return state.identities[identityId];
|
|
216
|
+
}
|
|
217
|
+
return Object.values(state.identities).find(
|
|
218
|
+
(identity) => identity.serverId === serverId,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async saveRenewalPolicy(serverId, policy, wallet, refillTargetAtomic) {
|
|
223
|
+
await this.update((state) => {
|
|
224
|
+
state.renewals[serverId] = {
|
|
225
|
+
...(state.renewals[serverId] || {}),
|
|
226
|
+
serverId,
|
|
227
|
+
policy,
|
|
228
|
+
wallet,
|
|
229
|
+
refillTargetAtomic,
|
|
230
|
+
updatedAt: new Date().toISOString(),
|
|
231
|
+
};
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async saveRenewalChallenge(serverId, challenge) {
|
|
236
|
+
await this.update((state) => {
|
|
237
|
+
const renewal = state.renewals[serverId] || { serverId };
|
|
238
|
+
Object.assign(renewal, challenge, {
|
|
239
|
+
challengeSavedAt: new Date().toISOString(),
|
|
240
|
+
});
|
|
241
|
+
state.renewals[serverId] = renewal;
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async saveRenewalPaymentAttempt(serverId, payment) {
|
|
246
|
+
await this.update((state) => {
|
|
247
|
+
const renewal = state.renewals[serverId];
|
|
248
|
+
if (!renewal) {
|
|
249
|
+
throw new CliError(`No local renewal state exists for ${serverId}.`, {
|
|
250
|
+
exitCode: 2,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
if (
|
|
254
|
+
renewal.paymentRequestDigest &&
|
|
255
|
+
renewal.paymentRequestDigest !== payment.requestDigest
|
|
256
|
+
) {
|
|
257
|
+
throw new CliError(
|
|
258
|
+
`The wallet payment attempt does not match the saved renewal for ${serverId}.`,
|
|
259
|
+
{ exitCode: 2 },
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
Object.assign(renewal, {
|
|
263
|
+
walletPaymentAttemptId: payment.attemptId,
|
|
264
|
+
walletBuyerPaymentIdentifier: payment.buyerPaymentIdentifier,
|
|
265
|
+
walletName: payment.wallet,
|
|
266
|
+
walletPayerAddress: payment.payerAddress,
|
|
267
|
+
paymentArtifactPath: payment.path,
|
|
268
|
+
paymentArtifactExpiresAt: payment.expiresAt,
|
|
269
|
+
paymentArtifactSavedAt: new Date().toISOString(),
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async renewal(serverId) {
|
|
275
|
+
return (await this.read()).renewals[serverId];
|
|
146
276
|
}
|
|
147
277
|
|
|
148
278
|
async savePaymentChallenge(
|
|
@@ -217,12 +347,13 @@ export class StateStore {
|
|
|
217
347
|
});
|
|
218
348
|
}
|
|
219
349
|
|
|
220
|
-
async saveOperation(operationId, serverId, kind) {
|
|
350
|
+
async saveOperation(operationId, serverId, kind, metadata = {}) {
|
|
221
351
|
await this.update((state) => {
|
|
222
352
|
state.operations[operationId] = {
|
|
223
353
|
operationId,
|
|
224
354
|
serverId,
|
|
225
355
|
kind,
|
|
356
|
+
...metadata,
|
|
226
357
|
createdAt: new Date().toISOString(),
|
|
227
358
|
};
|
|
228
359
|
});
|
|
@@ -350,6 +481,26 @@ export class StateStore {
|
|
|
350
481
|
runtimes: Object.values(state.runtimes),
|
|
351
482
|
sandboxes: Object.values(state.sandboxes),
|
|
352
483
|
accessGrants: Object.values(state.accessGrants),
|
|
484
|
+
identities: Object.values(state.identities).map((identity) => ({
|
|
485
|
+
identityId: identity.identityId,
|
|
486
|
+
keyName: identity.keyName,
|
|
487
|
+
hostnameAtCreation: identity.hostnameAtCreation,
|
|
488
|
+
publicKeyPath: identity.publicKeyPath,
|
|
489
|
+
privateKeyPath: identity.privateKeyPath,
|
|
490
|
+
sshFingerprint: identity.sshFingerprint,
|
|
491
|
+
serverId: identity.serverId,
|
|
492
|
+
taskId: identity.taskId,
|
|
493
|
+
createdAt: identity.createdAt,
|
|
494
|
+
boundAt: identity.boundAt,
|
|
495
|
+
})),
|
|
496
|
+
renewals: Object.values(state.renewals).map((renewal) => ({
|
|
497
|
+
serverId: renewal.serverId,
|
|
498
|
+
wallet: renewal.wallet,
|
|
499
|
+
refillTargetAtomic: renewal.refillTargetAtomic,
|
|
500
|
+
policy: renewal.policy,
|
|
501
|
+
paymentAttemptId: renewal.paymentAttemptId,
|
|
502
|
+
paymentArtifactExpiresAt: renewal.paymentArtifactExpiresAt,
|
|
503
|
+
})),
|
|
353
504
|
};
|
|
354
505
|
}
|
|
355
506
|
|