@haven_ai/signer 0.4.0-alpha.0 → 0.6.0-alpha.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 +198 -35
- package/dist/cli.cjs +1516 -1055
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +1681 -1220
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +963 -520
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +216 -128
- package/dist/index.d.ts +216 -128
- package/dist/index.js +886 -451
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/cli.cjs
CHANGED
|
@@ -1,27 +1,141 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
-
var mcp_js = require('@modelcontextprotocol/sdk/server/mcp.js');
|
|
5
|
-
var stdio_js = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
6
4
|
var crypto = require('crypto');
|
|
7
5
|
var promises = require('fs/promises');
|
|
8
|
-
var os = require('os');
|
|
9
6
|
var path = require('path');
|
|
10
|
-
var
|
|
7
|
+
var edge = require('@haven_ai/sdk/edge');
|
|
11
8
|
var viem = require('viem');
|
|
12
|
-
var accounts = require('viem/accounts');
|
|
13
|
-
var schemes = require('x402/schemes');
|
|
14
9
|
var v3 = require('zod/v3');
|
|
15
10
|
var zod = require('zod');
|
|
11
|
+
var os = require('os');
|
|
12
|
+
var mcp_js = require('@modelcontextprotocol/sdk/server/mcp.js');
|
|
13
|
+
var stdio_js = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
14
|
+
var accounts = require('viem/accounts');
|
|
15
|
+
|
|
16
|
+
var SIGNER_HOSTED_HANDOFF_SHAPES = {
|
|
17
|
+
haven_get_payment_status: { payment_id: zod.z.string().min(1) }
|
|
18
|
+
};
|
|
19
|
+
function target(shape) {
|
|
20
|
+
const schema = zod.z.object(shape).strict();
|
|
21
|
+
return {
|
|
22
|
+
role: "hosted",
|
|
23
|
+
validate: (input) => {
|
|
24
|
+
const r = schema.safeParse(input ?? {});
|
|
25
|
+
return r.success ? null : r.error.errors.map((e) => `${e.path.join(".") || "(root)"}: ${e.message}`).join("; ");
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
var SIGNER_NEXT_STEP_TARGETS = {
|
|
30
|
+
haven_get_payment_status: target(SIGNER_HOSTED_HANDOFF_SHAPES.haven_get_payment_status)
|
|
31
|
+
};
|
|
32
|
+
var nextStep = edge.createNextStepBuilder(SIGNER_NEXT_STEP_TARGETS);
|
|
33
|
+
function signerRefusalStep(input) {
|
|
34
|
+
return nextStep({ ...input, safeToContinue: false, reason: "" });
|
|
35
|
+
}
|
|
36
|
+
function nextStepWireFields(step) {
|
|
37
|
+
return {
|
|
38
|
+
...step.next_tool ? { next_tool: step.next_tool } : {},
|
|
39
|
+
...step.next_tool_server ? { next_tool_server: step.next_tool_server } : {},
|
|
40
|
+
...step.next_tool_name ? { next_tool_name: step.next_tool_name } : {},
|
|
41
|
+
...step.next_tool_server_role ? { next_tool_server_role: step.next_tool_server_role } : {},
|
|
42
|
+
...step.next_arguments ? { next_arguments: step.next_arguments } : {},
|
|
43
|
+
...step.next_tool_omitted_reason ? { next_tool_omitted_reason: step.next_tool_omitted_reason } : {}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// src/bare-hash.ts
|
|
48
|
+
var BARE_HASH_REFUSED = "BARE_HASH_REFUSED";
|
|
49
|
+
var HavenBareHashRefusedError = class extends edge.HavenSigningError {
|
|
50
|
+
next_action = edge.AgentPaymentNextAction.StopAndTellUser;
|
|
51
|
+
step;
|
|
52
|
+
constructor() {
|
|
53
|
+
super(
|
|
54
|
+
"Refusing to sign a bare payload_hash. A hash carries no shape, amount, merchant or binding, so this signer cannot tell a Haven payment from a transfer out of the delegate wallet or a delegation of its authority \u2014 and the only rail that ever validated a raw hash signature is retired. Call this tool with { payment_id } (the signer fetches and verifies the Haven-signed context itself), or with typed_data / typed_data_b64 (the account validates that EIP-712 payload), or with x402_expected for an x402 funding leg."
|
|
55
|
+
);
|
|
56
|
+
this.code = BARE_HASH_REFUSED;
|
|
57
|
+
this.name = "HavenBareHashRefusedError";
|
|
58
|
+
this.step = signerRefusalStep({
|
|
59
|
+
nextAction: edge.AgentPaymentNextAction.StopAndTellUser,
|
|
60
|
+
nextTool: null,
|
|
61
|
+
nextToolOmittedReason: "call haven_sign again with payment_id (preferred), typed_data / typed_data_b64, or x402_expected \u2014 a bare payload_hash is never signed"
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
var OWNER_ONLY_MODE = 384;
|
|
66
|
+
var defaultLog = (message) => process.stderr.write(`${message}
|
|
67
|
+
`);
|
|
68
|
+
async function permissiveMode(path, platform = process.platform, stats, follow = "nofollow") {
|
|
69
|
+
if (platform === "win32") return null;
|
|
70
|
+
let info;
|
|
71
|
+
try {
|
|
72
|
+
info = stats ?? (follow === "follow" ? await promises.stat(path) : await promises.lstat(path));
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
if ((info.mode & 63) === 0) return null;
|
|
77
|
+
return { octal: (info.mode & 511).toString(8).padStart(4, "0"), regular: info.isFile() };
|
|
78
|
+
}
|
|
79
|
+
async function warnIfFilePermissive(kind, path, log = defaultLog, platform = process.platform) {
|
|
80
|
+
const found = await permissiveMode(path, platform, void 0, "follow");
|
|
81
|
+
if (found === null) return;
|
|
82
|
+
log(
|
|
83
|
+
`haven-signer: warning: ${kind} at ${path} is readable beyond the owner (mode ${found.octal}). Run: chmod 600 ${path}`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
async function tightenIfFilePermissive(kind, path, log = defaultLog, platform = process.platform, stats) {
|
|
87
|
+
if (platform === "win32") return "skipped";
|
|
88
|
+
let info = stats ?? null;
|
|
89
|
+
if (!info) {
|
|
90
|
+
try {
|
|
91
|
+
info = await promises.lstat(path);
|
|
92
|
+
} catch {
|
|
93
|
+
return "owner-only";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (!info.isFile()) {
|
|
97
|
+
log(
|
|
98
|
+
`haven-signer: warning: ${kind} at ${path} is not a regular file (a symlink or similar); audit rows are being written through it and it was not tightened. Remove it: rm ${path}`
|
|
99
|
+
);
|
|
100
|
+
return "warned";
|
|
101
|
+
}
|
|
102
|
+
const found = await permissiveMode(path, platform, info);
|
|
103
|
+
if (found === null) return "owner-only";
|
|
104
|
+
{
|
|
105
|
+
try {
|
|
106
|
+
await promises.chmod(path, OWNER_ONLY_MODE);
|
|
107
|
+
log(
|
|
108
|
+
`haven-signer: ${kind} at ${path} was readable beyond the owner (mode ${found.octal}); tightened to 0600.`
|
|
109
|
+
);
|
|
110
|
+
return "tightened";
|
|
111
|
+
} catch {
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
log(
|
|
115
|
+
`haven-signer: warning: ${kind} at ${path} is readable beyond the owner (mode ${found.octal}) and could not be tightened. Run: chmod 600 ${path}`
|
|
116
|
+
);
|
|
117
|
+
return "warned";
|
|
118
|
+
}
|
|
16
119
|
|
|
120
|
+
// src/audit.ts
|
|
121
|
+
var AUDIT_ROTATE_BYTES = 8 * 1024 * 1024;
|
|
17
122
|
function defaultSigningAuditPath(credentialsPath) {
|
|
18
123
|
if (credentialsPath) return path.resolve(`${credentialsPath}.signer-audit.jsonl`);
|
|
19
124
|
return path.resolve(os.homedir(), ".haven", "signer-audit.jsonl");
|
|
20
125
|
}
|
|
21
|
-
async function appendSigningAuditEntry(entry, path$1) {
|
|
126
|
+
async function appendSigningAuditEntry(entry, path$1, options = {}) {
|
|
22
127
|
await promises.mkdir(path.dirname(path$1), { recursive: true });
|
|
128
|
+
const rotateAt = options.rotateAtBytes ?? AUDIT_ROTATE_BYTES;
|
|
129
|
+
const existing = await promises.lstat(path$1).catch(() => null);
|
|
130
|
+
if (existing) {
|
|
131
|
+
await tightenIfFilePermissive("audit sidecar", path$1, options.log, options.platform, existing);
|
|
132
|
+
if (existing.size >= rotateAt) {
|
|
133
|
+
await promises.rename(path$1, `${path$1}.1`).catch(() => {
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
23
137
|
await promises.appendFile(path$1, `${JSON.stringify(entry)}
|
|
24
|
-
`, "utf8");
|
|
138
|
+
`, { encoding: "utf8", mode: OWNER_ONLY_MODE });
|
|
25
139
|
}
|
|
26
140
|
function createSigningAuditEntry(tool, payloadHash, context, now = /* @__PURE__ */ new Date()) {
|
|
27
141
|
const entry = {
|
|
@@ -47,685 +161,442 @@ function stableStringify(value) {
|
|
|
47
161
|
const object = value;
|
|
48
162
|
return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(object[key])}`).join(",")}}`;
|
|
49
163
|
}
|
|
50
|
-
var
|
|
51
|
-
var
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
164
|
+
var DIRECT_RELAY_FALLBACK = "call haven_sign with payload_hash and typed_data_b64 from the haven_send / haven_pay result, passed through unchanged";
|
|
165
|
+
var HavenSignContextError = class extends edge.HavenSigningError {
|
|
166
|
+
/**
|
|
167
|
+
* #3001 / #3010 review: the recovery path, when signing OTHER bytes is
|
|
168
|
+
* actually a remedy — a transport failure (timeout, unreachable) or a
|
|
169
|
+
* body this signer could not read. `typed_data_b64` is NOT in the default
|
|
170
|
+
* quote result since #1272: obtain it by re-running the SAME quote tool
|
|
171
|
+
* with the SAME idempotency_key plus `include_signing_payload: true`.
|
|
172
|
+
* Absent on a backend REFUSAL (an expired, executed or unsignable intent
|
|
173
|
+
* cannot be rescued by re-signing its bytes) — with one #3271 exception: a
|
|
174
|
+
* 404 from the DIRECT fetch (an older backend with no direct route), where
|
|
175
|
+
* the `haven_send` / `haven_pay` result's relay fields still work.
|
|
176
|
+
*/
|
|
177
|
+
fallback;
|
|
178
|
+
/**
|
|
179
|
+
* #3001: `AgentPaymentNextAction` values the signer already emits — the
|
|
180
|
+
* version-mismatch refusal's `stop_and_tell_user` for the classes where
|
|
181
|
+
* retrying the same call cannot help, and `payment_window_expired` (with
|
|
182
|
+
* `retry_with_new_quote`) for the x402 fetch's 410 / `expired`, exactly as
|
|
183
|
+
* the plain `HavenError` branch already does for that code. On the direct
|
|
184
|
+
* fetch (#3271) only `error_code: 'expired'` means an expired window, with
|
|
185
|
+
* no `retry_with_new_quote`; a bare 410 there is a retired-rail tombstone.
|
|
186
|
+
*/
|
|
187
|
+
next_action;
|
|
188
|
+
/**
|
|
189
|
+
* #3103 (epic #3105, decisions 1 and 3): the typed next step beside the
|
|
190
|
+
* action. A refusal Haven made (`SIGN_CONTEXT_REFUSED`, not expired) names
|
|
191
|
+
* the hosted status read with the payment id — except a 404 from the direct
|
|
192
|
+
* fetch (#3271), which names none and points at the `typed_data_b64`
|
|
193
|
+
* relay; a transport failure or a
|
|
194
|
+
* malformed body names no tool — the remedy is re-running the SAME quote
|
|
195
|
+
* tool with `include_signing_payload: true` (x402), or the payment
|
|
196
|
+
* result's `typed_data_b64` relay (direct, #3271) — and an expired window names
|
|
197
|
+
* none either (which quote tool depends on the flow). Never null: a step
|
|
198
|
+
* with no tool carries `next_tool_omitted_reason`. Additive: the class is
|
|
199
|
+
* not exported from the package index — the published surface is the
|
|
200
|
+
* `ToolFailure` envelope `tools.ts` builds from these fields — and every
|
|
201
|
+
* constructor call site is in this file.
|
|
202
|
+
*/
|
|
203
|
+
next_tool;
|
|
204
|
+
next_tool_server;
|
|
205
|
+
next_tool_name;
|
|
206
|
+
next_tool_server_role;
|
|
207
|
+
next_arguments;
|
|
208
|
+
next_tool_omitted_reason;
|
|
209
|
+
retry_with_new_quote;
|
|
210
|
+
/** Present only for `SIGN_CONTEXT_REFUSED` (the backend's HTTP status). */
|
|
211
|
+
http_status;
|
|
212
|
+
/** Present only for `SIGN_CONTEXT_REFUSED`: the backend's own `error_code`. */
|
|
213
|
+
backend_error_code;
|
|
214
|
+
/**
|
|
215
|
+
* #3303: on a 426 `client_outdated` refusal, the backend's update hint —
|
|
216
|
+
* this signer is below the minimum the deployment set, and
|
|
217
|
+
* `client_update.upgrade_command` is what updates it.
|
|
218
|
+
*/
|
|
219
|
+
client_update;
|
|
220
|
+
constructor(message, code, refusal, paymentId, flow = "x402") {
|
|
221
|
+
super(message);
|
|
222
|
+
this.code = code;
|
|
223
|
+
this.name = "HavenSignContextError";
|
|
224
|
+
let step;
|
|
225
|
+
if (code === "SIGN_CONTEXT_REFUSED") {
|
|
226
|
+
this.http_status = refusal?.httpStatus;
|
|
227
|
+
this.backend_error_code = refusal?.errorCode;
|
|
228
|
+
if (refusal?.errorCode === "client_outdated") {
|
|
229
|
+
this.client_update = refusal.clientUpdate;
|
|
230
|
+
this.next_action = edge.AgentPaymentNextAction.StopAndTellUser;
|
|
231
|
+
step = signerRefusalStep({
|
|
232
|
+
nextAction: edge.AgentPaymentNextAction.StopAndTellUser,
|
|
233
|
+
nextTool: null,
|
|
234
|
+
nextToolOmittedReason: `this signer is below the minimum version Haven accepts \u2014 tell the user to run ${refusal.clientUpdate?.upgrade_command ?? "the connector again"}, restart the agent runtime, then retry the same haven_sign / haven_sign_x402 call`
|
|
235
|
+
});
|
|
236
|
+
} else if (flow === "direct" && refusal?.httpStatus === 404) {
|
|
237
|
+
this.fallback = "typed_data_b64";
|
|
238
|
+
this.next_action = edge.AgentPaymentNextAction.StopAndTellUser;
|
|
239
|
+
step = signerRefusalStep({
|
|
240
|
+
nextAction: edge.AgentPaymentNextAction.StopAndTellUser,
|
|
241
|
+
nextTool: null,
|
|
242
|
+
nextToolOmittedReason: DIRECT_RELAY_FALLBACK
|
|
243
|
+
});
|
|
244
|
+
} else if (flow === "direct" && refusal?.errorCode === "expired") {
|
|
245
|
+
this.next_action = edge.AgentPaymentNextAction.PaymentWindowExpired;
|
|
246
|
+
step = signerRefusalStep({
|
|
247
|
+
nextAction: edge.AgentPaymentNextAction.PaymentWindowExpired,
|
|
248
|
+
nextTool: null,
|
|
249
|
+
nextToolOmittedReason: "call haven_send / haven_pay again with the same idempotency_key \u2014 the expired payment frees it"
|
|
250
|
+
});
|
|
251
|
+
} else if (flow === "x402" && (refusal?.httpStatus === 410 || refusal?.errorCode === "expired")) {
|
|
252
|
+
this.next_action = edge.AgentPaymentNextAction.PaymentWindowExpired;
|
|
253
|
+
this.retry_with_new_quote = true;
|
|
254
|
+
step = signerRefusalStep({
|
|
255
|
+
nextAction: edge.AgentPaymentNextAction.PaymentWindowExpired,
|
|
256
|
+
nextTool: null,
|
|
257
|
+
nextToolOmittedReason: "re-run the hosted quote tool you called with the same idempotency_key; which one depends on the flow"
|
|
258
|
+
});
|
|
259
|
+
} else if (flow === "task") {
|
|
260
|
+
this.next_action = edge.AgentPaymentNextAction.StopAndTellUser;
|
|
261
|
+
step = signerRefusalStep({
|
|
262
|
+
nextAction: edge.AgentPaymentNextAction.StopAndTellUser,
|
|
263
|
+
nextTool: null,
|
|
264
|
+
nextToolOmittedReason: `re-check this task budget's status (id ${paymentId}) with the hosted Haven server before retrying`
|
|
265
|
+
});
|
|
266
|
+
} else {
|
|
267
|
+
this.next_action = edge.AgentPaymentNextAction.StopAndTellUser;
|
|
268
|
+
step = signerRefusalStep({
|
|
269
|
+
nextAction: edge.AgentPaymentNextAction.StopAndTellUser,
|
|
270
|
+
nextTool: "haven_get_payment_status",
|
|
271
|
+
nextArguments: { payment_id: paymentId }
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
} else if (flow === "task") {
|
|
275
|
+
this.next_action = edge.AgentPaymentNextAction.StopAndTellUser;
|
|
276
|
+
step = signerRefusalStep({
|
|
277
|
+
nextAction: edge.AgentPaymentNextAction.StopAndTellUser,
|
|
278
|
+
nextTool: null,
|
|
279
|
+
nextToolOmittedReason: "retry the same haven_sign call with the same task_budget_id"
|
|
280
|
+
});
|
|
281
|
+
} else {
|
|
282
|
+
this.fallback = "typed_data_b64";
|
|
283
|
+
this.next_action = edge.AgentPaymentNextAction.StopAndTellUser;
|
|
284
|
+
step = signerRefusalStep({
|
|
285
|
+
nextAction: edge.AgentPaymentNextAction.StopAndTellUser,
|
|
286
|
+
nextTool: null,
|
|
287
|
+
nextToolOmittedReason: flow === "direct" ? DIRECT_RELAY_FALLBACK : "re-run the SAME hosted quote tool with the same idempotency_key and include_signing_payload: true, then pass its typed_data_b64 to this signer"
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
Object.assign(this, nextStepWireFields(step));
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
async function loadHavenIdentity(credentialsPath) {
|
|
294
|
+
if (!credentialsPath) return null;
|
|
295
|
+
try {
|
|
296
|
+
const raw = JSON.parse(
|
|
297
|
+
await promises.readFile(path.join(path.dirname(credentialsPath), "identity.json"), "utf8")
|
|
298
|
+
);
|
|
299
|
+
const apiKey = typeof raw.api_key === "string" ? raw.api_key : void 0;
|
|
300
|
+
const apiUrl = typeof raw.api_url === "string" ? raw.api_url : void 0;
|
|
301
|
+
if (!apiKey || !apiUrl) return null;
|
|
302
|
+
return { apiKey, apiUrl: apiUrl.replace(/\/+$/, "") };
|
|
303
|
+
} catch {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
69
306
|
}
|
|
70
|
-
|
|
71
|
-
|
|
307
|
+
var SIGN_CONTEXT_TIMEOUT_MS = 15e3;
|
|
308
|
+
function signContextHeaders(identity, clientIdentity) {
|
|
309
|
+
return {
|
|
310
|
+
Authorization: `Bearer ${identity.apiKey}`,
|
|
311
|
+
...clientIdentity ? { [edge.HAVEN_CLIENT_HEADER]: clientIdentity } : {}
|
|
312
|
+
};
|
|
72
313
|
}
|
|
73
|
-
function
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
314
|
+
function refusalOf(status, body) {
|
|
315
|
+
return {
|
|
316
|
+
httpStatus: status,
|
|
317
|
+
errorCode: typeof body.error_code === "string" ? body.error_code : void 0,
|
|
318
|
+
clientUpdate: edge.readClientUpdate(body.client_update)
|
|
319
|
+
};
|
|
77
320
|
}
|
|
78
|
-
function
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if (typedData.primaryType !== "Delegation") {
|
|
88
|
-
refuse("it is not a Delegation payload", `primaryType was '${typedData.primaryType}'.`);
|
|
89
|
-
}
|
|
90
|
-
if (!same(typedData.domain?.verifyingContract, DELEGATION_MANAGER)) {
|
|
91
|
-
refuse(
|
|
92
|
-
"the EIP-712 domain names an unknown DelegationManager",
|
|
93
|
-
`Expected ${DELEGATION_MANAGER}, got ${typedData.domain?.verifyingContract}.`
|
|
321
|
+
async function fetchX402SignContext(identity, paymentId, fetchImpl = fetch, timeoutMs = SIGN_CONTEXT_TIMEOUT_MS, clientIdentity) {
|
|
322
|
+
let response;
|
|
323
|
+
try {
|
|
324
|
+
response = await fetchImpl(
|
|
325
|
+
`${identity.apiUrl}/x402/${encodeURIComponent(paymentId)}/sign-context`,
|
|
326
|
+
{
|
|
327
|
+
headers: signContextHeaders(identity, clientIdentity),
|
|
328
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
329
|
+
}
|
|
94
330
|
);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
331
|
+
} catch (err) {
|
|
332
|
+
const timedOut = err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
|
|
333
|
+
throw new HavenSignContextError(
|
|
334
|
+
timedOut ? `Haven did not answer the signing-context fetch for ${paymentId} within ${timeoutMs} ms. Retry, or pass typed_data_b64 from the quote result instead.` : `Could not reach Haven to fetch the signing context for ${paymentId}: ${err instanceof Error ? err.message : String(err)}. Retry, or pass typed_data_b64 from the quote result instead.`,
|
|
335
|
+
timedOut ? "SIGN_CONTEXT_TIMEOUT" : "SIGN_CONTEXT_UNREACHABLE",
|
|
336
|
+
void 0,
|
|
337
|
+
paymentId
|
|
101
338
|
);
|
|
102
339
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
340
|
+
let body;
|
|
341
|
+
try {
|
|
342
|
+
body = await response.json();
|
|
343
|
+
} catch (err) {
|
|
344
|
+
if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
|
|
345
|
+
throw new HavenSignContextError(
|
|
346
|
+
`Haven did not finish sending the signing context for ${paymentId} within ${timeoutMs} ms. Retry, or pass typed_data_b64 from the quote result instead.`,
|
|
347
|
+
"SIGN_CONTEXT_TIMEOUT",
|
|
348
|
+
void 0,
|
|
349
|
+
paymentId
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
body = {};
|
|
106
353
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
"
|
|
354
|
+
if (!response.ok) {
|
|
355
|
+
const detail = typeof body.error === "string" ? body.error : `HTTP ${response.status}`;
|
|
356
|
+
throw new HavenSignContextError(
|
|
357
|
+
`Haven refused the signing-context fetch for ${paymentId}: ${detail}` + (response.status === 404 ? " \u2014 check the payment_id came from this agent\u2019s own quote." : response.status === 410 ? " Re-run the quote with the same idempotency key, then sign the fresh payment_id." : ""),
|
|
358
|
+
"SIGN_CONTEXT_REFUSED",
|
|
359
|
+
refusalOf(response.status, body),
|
|
360
|
+
paymentId
|
|
112
361
|
);
|
|
113
362
|
}
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
`The expected context says ${expected.amount}; the child allows ${amount.toString()}.`
|
|
363
|
+
const signData = body.sign_data;
|
|
364
|
+
const x402Expected = body.x402_expected;
|
|
365
|
+
if (!signData || typeof signData.hash !== "string" || !signData.typed_data || typeof signData.typed_data !== "object" || !x402Expected) {
|
|
366
|
+
throw new HavenSignContextError(
|
|
367
|
+
"The Haven sign-context response is missing sign_data.typed_data or x402_expected \u2014 the backend may predate #1263. Pass typed_data_b64 from the quote result instead.",
|
|
368
|
+
"SIGN_CONTEXT_MALFORMED",
|
|
369
|
+
void 0,
|
|
370
|
+
paymentId
|
|
123
371
|
);
|
|
124
372
|
}
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
373
|
+
const paymentRequired = body.payment_required;
|
|
374
|
+
return {
|
|
375
|
+
paymentId: String(body.payment_id ?? paymentId),
|
|
376
|
+
payloadHash: signData.hash,
|
|
377
|
+
typedData: signData.typed_data,
|
|
378
|
+
x402Expected,
|
|
379
|
+
paymentRequired: paymentRequired && typeof paymentRequired === "object" && !Array.isArray(paymentRequired) ? paymentRequired : null,
|
|
380
|
+
clientUpdate: edge.readClientUpdate(body.client_update)
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
var SUPPORTED_DIRECT_SIGN_CONTEXT_VERSIONS = [edge.DIRECT_SIGN_CONTEXT_VERSION];
|
|
384
|
+
async function fetchDirectSignContext(identity, paymentId, fetchImpl = fetch, timeoutMs = SIGN_CONTEXT_TIMEOUT_MS, clientIdentity) {
|
|
385
|
+
let response;
|
|
386
|
+
try {
|
|
387
|
+
response = await fetchImpl(
|
|
388
|
+
`${identity.apiUrl}/payments/${encodeURIComponent(paymentId)}/sign-context`,
|
|
389
|
+
{
|
|
390
|
+
headers: signContextHeaders(identity, clientIdentity),
|
|
391
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
392
|
+
}
|
|
130
393
|
);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
394
|
+
} catch (err) {
|
|
395
|
+
const timedOut = err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
|
|
396
|
+
throw new HavenSignContextError(
|
|
397
|
+
timedOut ? `Haven did not answer the direct-payment signing-context fetch for ${paymentId} within ${timeoutMs} ms. Retry, or pass typed_data_b64 from the payment result instead.` : `Could not reach Haven to fetch the direct-payment signing context for ${paymentId}: ${err instanceof Error ? err.message : String(err)}. Retry, or pass typed_data_b64 from the payment result instead.`,
|
|
398
|
+
timedOut ? "SIGN_CONTEXT_TIMEOUT" : "SIGN_CONTEXT_UNREACHABLE",
|
|
399
|
+
void 0,
|
|
400
|
+
paymentId,
|
|
401
|
+
"direct"
|
|
137
402
|
);
|
|
138
403
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
404
|
+
let body;
|
|
405
|
+
try {
|
|
406
|
+
body = await response.json();
|
|
407
|
+
} catch (err) {
|
|
408
|
+
if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
|
|
409
|
+
throw new HavenSignContextError(
|
|
410
|
+
`Haven did not finish sending the direct-payment signing context for ${paymentId} within ${timeoutMs} ms. Retry, or pass typed_data_b64 from the payment result instead.`,
|
|
411
|
+
"SIGN_CONTEXT_TIMEOUT",
|
|
412
|
+
void 0,
|
|
413
|
+
paymentId,
|
|
414
|
+
"direct"
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
body = {};
|
|
142
418
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
"
|
|
147
|
-
|
|
419
|
+
if (!response.ok) {
|
|
420
|
+
const detail = typeof body.error === "string" ? body.error : `HTTP ${response.status}`;
|
|
421
|
+
throw new HavenSignContextError(
|
|
422
|
+
`Haven refused the direct-payment signing-context fetch for ${paymentId}: ${detail}` + (response.status === 404 ? " \u2014 either the payment_id is not from this agent\u2019s own POST /payments call, or this Haven backend predates #3271. Pass payload_hash and typed_data_b64 from the haven_send / haven_pay result, unchanged, instead." : body.error_code === "expired" ? " The payment window has expired; call haven_send / haven_pay again with the same idempotency_key." : ""),
|
|
423
|
+
"SIGN_CONTEXT_REFUSED",
|
|
424
|
+
refusalOf(response.status, body),
|
|
425
|
+
paymentId,
|
|
426
|
+
"direct"
|
|
148
427
|
);
|
|
149
428
|
}
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if (beforeThreshold <= nowSec) {
|
|
160
|
-
refuse("it has already expired", `Expiry ${beforeThreshold} is not in the future.`);
|
|
161
|
-
}
|
|
162
|
-
if (beforeThreshold > nowSec + MAX_SETTLEMENT_WINDOW_SECONDS) {
|
|
163
|
-
refuse(
|
|
164
|
-
"its window is longer than a settlement may live",
|
|
165
|
-
`Expiry is ${beforeThreshold - nowSec}s out; the ceiling is ${MAX_SETTLEMENT_WINDOW_SECONDS}s.`
|
|
429
|
+
const signData = body.sign_data;
|
|
430
|
+
const version = body.direct_sign_context_version;
|
|
431
|
+
if (!signData || typeof signData.hash !== "string" || !signData.typed_data || typeof signData.typed_data !== "object" || signData.signature_scheme !== "eip712_userop" || typeof version !== "number" || !SUPPORTED_DIRECT_SIGN_CONTEXT_VERSIONS.includes(version)) {
|
|
432
|
+
throw new HavenSignContextError(
|
|
433
|
+
typeof version === "number" && !SUPPORTED_DIRECT_SIGN_CONTEXT_VERSIONS.includes(version) ? `The Haven direct-payment sign-context response is version ${version}, which this signer does not support (supported: ${SUPPORTED_DIRECT_SIGN_CONTEXT_VERSIONS.join(", ")}). Update @haven_ai/signer.` : "The Haven direct-payment sign-context response is missing sign_data.typed_data, or its signature_scheme is not 'eip712_userop' \u2014 the backend may predate #3271. Pass typed_data_b64 from the payment result instead.",
|
|
434
|
+
"SIGN_CONTEXT_MALFORMED",
|
|
435
|
+
void 0,
|
|
436
|
+
paymentId,
|
|
437
|
+
"direct"
|
|
166
438
|
);
|
|
167
439
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
440
|
+
return {
|
|
441
|
+
paymentId: String(body.payment_id ?? paymentId),
|
|
442
|
+
payloadHash: signData.hash,
|
|
443
|
+
typedData: signData.typed_data,
|
|
444
|
+
status: typeof body.status === "string" ? body.status : "pending_signature",
|
|
445
|
+
expiresAt: typeof body.expires_at === "string" ? body.expires_at : void 0,
|
|
446
|
+
clientUpdate: edge.readClientUpdate(body.client_update)
|
|
447
|
+
};
|
|
177
448
|
}
|
|
178
|
-
|
|
179
|
-
|
|
449
|
+
var SUPPORTED_TASK_SIGN_CONTEXT_VERSIONS = [1];
|
|
450
|
+
async function fetchTaskBudgetSignContext(identity, taskBudgetId, fetchImpl = fetch, timeoutMs = SIGN_CONTEXT_TIMEOUT_MS, clientIdentity) {
|
|
451
|
+
let response;
|
|
180
452
|
try {
|
|
181
|
-
|
|
453
|
+
response = await fetchImpl(
|
|
454
|
+
`${identity.apiUrl}/task-budgets/${encodeURIComponent(taskBudgetId)}/sign-context`,
|
|
455
|
+
{
|
|
456
|
+
headers: signContextHeaders(identity, clientIdentity),
|
|
457
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
458
|
+
}
|
|
459
|
+
);
|
|
182
460
|
} catch (err) {
|
|
183
|
-
|
|
184
|
-
|
|
461
|
+
const timedOut = err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
|
|
462
|
+
throw new HavenSignContextError(
|
|
463
|
+
timedOut ? `Haven did not answer the task-budget signing-context fetch for ${taskBudgetId} within ${timeoutMs} ms. Retry.` : `Could not reach Haven to fetch the task-budget signing context for ${taskBudgetId}: ${err instanceof Error ? err.message : String(err)}.`,
|
|
464
|
+
timedOut ? "SIGN_CONTEXT_TIMEOUT" : "SIGN_CONTEXT_UNREACHABLE",
|
|
465
|
+
void 0,
|
|
466
|
+
taskBudgetId,
|
|
467
|
+
"task"
|
|
185
468
|
);
|
|
186
469
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const signature = sdk.signHash(delegateKey, hash);
|
|
199
|
-
if (!sdk.verifySignature(hash, signature, delegateAddress)) {
|
|
200
|
-
throw new sdk.HavenSigningError(
|
|
201
|
-
"Local signature verification failed \u2014 recovered address does not match the delegate key."
|
|
202
|
-
);
|
|
203
|
-
}
|
|
204
|
-
return signature;
|
|
205
|
-
}
|
|
206
|
-
return {
|
|
207
|
-
delegateAddress,
|
|
208
|
-
signPaymentHash(hash) {
|
|
209
|
-
return signAndVerify(hash);
|
|
210
|
-
},
|
|
211
|
-
async signDelegationTypedData(typedData) {
|
|
212
|
-
const account = accounts.privateKeyToAccount(delegateKey);
|
|
213
|
-
return account.signTypedData(typedData);
|
|
214
|
-
},
|
|
215
|
-
signX402FundingHash(hash, expected) {
|
|
216
|
-
assertExpectedBinding(hash, expected, options.x402BindingSigner, "hash");
|
|
217
|
-
assertPayerMatchesDelegate(expected, delegateAddress, options.agentId);
|
|
218
|
-
const signature = signAndVerify(hash);
|
|
219
|
-
const x402Binding = crypto.randomUUID();
|
|
220
|
-
x402Bindings.set(x402Binding, { ...expected });
|
|
221
|
-
return { signature, x402Binding };
|
|
222
|
-
},
|
|
223
|
-
async signX402FundingTypedData(typedData, expected) {
|
|
224
|
-
assertExpectedBinding(expected.payloadHash, expected, options.x402BindingSigner, "typed-data");
|
|
225
|
-
assertPayerMatchesDelegate(expected, delegateAddress, options.agentId);
|
|
226
|
-
const digest = viem.hashTypedData(typedData);
|
|
227
|
-
if (digest.toLowerCase() !== expected.typedDataHash?.toLowerCase()) {
|
|
228
|
-
throw new sdk.HavenSigningError(
|
|
229
|
-
"x402 typed data does not match the digest Haven committed to in the expected context. Refusing to sign \u2014 the payload was altered in transit or Haven declared a different one. The most common cause is the typed data being truncated or reshaped while being copied between tool calls (#1255): re-run the hosted quote and pass its typed_data_b64 string through UNCHANGED instead of re-emitting the nested JSON."
|
|
230
|
-
);
|
|
231
|
-
}
|
|
232
|
-
if (isSettlementChildTypedData(typedData)) {
|
|
233
|
-
const settlementChainId = chainIdForNetwork(expected.network);
|
|
234
|
-
if (settlementChainId === void 0) {
|
|
235
|
-
throw new sdk.HavenSigningError(
|
|
236
|
-
`Refusing to sign the x402 settlement child: this signer cannot map the network '${expected.network}' to a chain id, so it cannot check which chain the child is scoped to. Update @haven_ai/signer.`
|
|
237
|
-
);
|
|
238
|
-
}
|
|
239
|
-
verifySettlementChild(typedData, {
|
|
240
|
-
merchantTo: expected.merchantTo,
|
|
241
|
-
amount: expected.amount,
|
|
242
|
-
asset: expected.asset,
|
|
243
|
-
// From the SIGNED network, not the payload's own claim. Passing
|
|
244
|
-
// Number(typedData.domain.chainId) here made the check compare a
|
|
245
|
-
// value to itself — vacuous, and precisely the "declared one thing,
|
|
246
|
-
// signed another" class this file exists to catch (#1455 review).
|
|
247
|
-
chainId: settlementChainId,
|
|
248
|
-
expiresAt: expected.expiresAt
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
const account = accounts.privateKeyToAccount(delegateKey);
|
|
252
|
-
const signature = await account.signTypedData(
|
|
253
|
-
typedData
|
|
254
|
-
);
|
|
255
|
-
const x402Binding = crypto.randomUUID();
|
|
256
|
-
x402Bindings.set(x402Binding, { ...expected });
|
|
257
|
-
return { signature, x402Binding };
|
|
258
|
-
},
|
|
259
|
-
async buildX402PaymentHeader(paymentRequired, x402Binding) {
|
|
260
|
-
const expected = x402Bindings.get(x402Binding);
|
|
261
|
-
if (!expected) {
|
|
262
|
-
const retired = retiredX402Bindings.get(x402Binding);
|
|
263
|
-
if (retired === "header_built") {
|
|
264
|
-
throw new sdk.HavenSigningError(
|
|
265
|
-
"This x402 binding was already used to build a merchant header. Bindings are single-use. If you called haven_sign_x402, it already returned the payment_header \u2014 retry the merchant with THAT header instead of building another; haven_x402_sign_header is the follow-up to haven_sign, not to haven_sign_x402. If you no longer have the header, re-run the quote tool with the same idempotency_key."
|
|
266
|
-
);
|
|
267
|
-
}
|
|
268
|
-
if (retired === "window_expired") {
|
|
269
|
-
throw new sdk.HavenSigningError(
|
|
270
|
-
"This x402 binding was retired because its payment window closed before a merchant header could be built \u2014 no header exists to retry with. Re-run the quote tool with the same idempotency_key to get a fresh window, then sign again."
|
|
271
|
-
);
|
|
272
|
-
}
|
|
273
|
-
throw new sdk.HavenSigningError(
|
|
274
|
-
"x402 funding binding is required before signing a merchant header. Sign the hosted funding hash with x402_expected first (haven_sign returns a binding this tool can use). A binding is also lost when the signer process restarts, since bindings live in memory only \u2014 re-sign to mint a fresh one."
|
|
275
|
-
);
|
|
276
|
-
}
|
|
277
|
-
try {
|
|
278
|
-
assertX402PaymentWindowOpen(expected);
|
|
279
|
-
} catch (err) {
|
|
280
|
-
x402Bindings.delete(x402Binding);
|
|
281
|
-
retireX402Binding(x402Binding, "window_expired");
|
|
282
|
-
throw err;
|
|
283
|
-
}
|
|
284
|
-
const option = sdk.selectStandardPaymentOption(paymentRequired.accepts);
|
|
285
|
-
if (!option) {
|
|
286
|
-
throw new sdk.HavenApiError(
|
|
287
|
-
"No compatible payment option found in x402 requirements. Haven supports standard x402 exact payments on Base USDC.",
|
|
288
|
-
400
|
|
289
|
-
);
|
|
290
|
-
}
|
|
291
|
-
assertX402MatchesExpected(paymentRequired, option, expected);
|
|
292
|
-
const account = accounts.privateKeyToAccount(delegateKey);
|
|
293
|
-
const requirements = sdk.toStandardPaymentRequirements(paymentRequired, option);
|
|
294
|
-
const header = await schemes.exact.evm.createPaymentHeader(
|
|
295
|
-
account,
|
|
296
|
-
paymentRequired.x402Version,
|
|
297
|
-
requirements
|
|
470
|
+
let body;
|
|
471
|
+
try {
|
|
472
|
+
body = await response.json();
|
|
473
|
+
} catch (err) {
|
|
474
|
+
if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
|
|
475
|
+
throw new HavenSignContextError(
|
|
476
|
+
`Haven did not finish sending the task-budget signing context for ${taskBudgetId} within ${timeoutMs} ms. Retry.`,
|
|
477
|
+
"SIGN_CONTEXT_TIMEOUT",
|
|
478
|
+
void 0,
|
|
479
|
+
taskBudgetId,
|
|
480
|
+
"task"
|
|
298
481
|
);
|
|
299
|
-
if (paymentRequired.x402Version < 2) {
|
|
300
|
-
x402Bindings.delete(x402Binding);
|
|
301
|
-
retireX402Binding(x402Binding, "header_built");
|
|
302
|
-
return { paymentHeader: header, accepted: option };
|
|
303
|
-
}
|
|
304
|
-
try {
|
|
305
|
-
const payment = sdk.decodeBase64Json(header);
|
|
306
|
-
const wrapped = sdk.encodeBase64Json(
|
|
307
|
-
sdk.x402V2PaymentEnvelope(paymentRequired, option, payment.payload)
|
|
308
|
-
);
|
|
309
|
-
return { paymentHeader: wrapped, accepted: option };
|
|
310
|
-
} finally {
|
|
311
|
-
x402Bindings.delete(x402Binding);
|
|
312
|
-
retireX402Binding(x402Binding, "header_built");
|
|
313
|
-
}
|
|
314
|
-
},
|
|
315
|
-
async signSweepAuthorization({
|
|
316
|
-
authorization,
|
|
317
|
-
expectedAuth,
|
|
318
|
-
expectedSafe
|
|
319
|
-
}) {
|
|
320
|
-
assertSweepBinding(authorization, expectedAuth, options.x402BindingSigner);
|
|
321
|
-
if (!sameAddress(authorization.from, delegateAddress)) {
|
|
322
|
-
throw new sdk.HavenSigningError(
|
|
323
|
-
"Sweep authorization `from` does not match this delegate address."
|
|
324
|
-
);
|
|
325
|
-
}
|
|
326
|
-
if (expectedSafe && !sameAddress(authorization.to, expectedSafe)) {
|
|
327
|
-
throw new sdk.HavenSigningError(
|
|
328
|
-
"Sweep authorization `to` does not match the Safe in the local credential."
|
|
329
|
-
);
|
|
330
|
-
}
|
|
331
|
-
const typedData = sdk.buildSweepTypedData(authorization);
|
|
332
|
-
const viemTypedData = {
|
|
333
|
-
domain: {
|
|
334
|
-
...typedData.domain,
|
|
335
|
-
verifyingContract: typedData.domain.verifyingContract
|
|
336
|
-
},
|
|
337
|
-
types: typedData.types,
|
|
338
|
-
primaryType: typedData.primaryType,
|
|
339
|
-
message: {
|
|
340
|
-
...typedData.message,
|
|
341
|
-
from: typedData.message.from,
|
|
342
|
-
to: typedData.message.to,
|
|
343
|
-
nonce: typedData.message.nonce
|
|
344
|
-
}
|
|
345
|
-
};
|
|
346
|
-
const account = accounts.privateKeyToAccount(delegateKey);
|
|
347
|
-
const signature = await account.signTypedData(viemTypedData);
|
|
348
|
-
const recovered = await viem.recoverTypedDataAddress({ ...viemTypedData, signature });
|
|
349
|
-
if (!sameAddress(recovered, delegateAddress)) {
|
|
350
|
-
throw new sdk.HavenSigningError(
|
|
351
|
-
"Local sweep signature verification failed \u2014 recovered address does not match the delegate key."
|
|
352
|
-
);
|
|
353
|
-
}
|
|
354
|
-
return { signature };
|
|
355
482
|
}
|
|
356
|
-
|
|
357
|
-
}
|
|
358
|
-
function assertSweepBinding(authorization, expectedAuth, trustedSigner) {
|
|
359
|
-
if (!expectedAuth || typeof expectedAuth !== "object") {
|
|
360
|
-
throw new sdk.HavenSigningError("Sweep authorization binding is required before signing.");
|
|
483
|
+
body = {};
|
|
361
484
|
}
|
|
362
|
-
if (!
|
|
363
|
-
|
|
364
|
-
|
|
485
|
+
if (!response.ok) {
|
|
486
|
+
const detail = typeof body.error === "string" ? body.error : `HTTP ${response.status}`;
|
|
487
|
+
throw new HavenSignContextError(
|
|
488
|
+
`Haven refused the task-budget signing-context fetch for ${taskBudgetId}: ${detail}`,
|
|
489
|
+
"SIGN_CONTEXT_REFUSED",
|
|
490
|
+
refusalOf(response.status, body),
|
|
491
|
+
taskBudgetId,
|
|
492
|
+
"task"
|
|
365
493
|
);
|
|
366
494
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
)
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
}
|
|
379
|
-
if (!sdk.verifySignature(viem.hashMessage(message), expectedAuth.signature, trustedSigner)) {
|
|
380
|
-
throw new sdk.HavenSigningError("Sweep authorization binding signature could not be verified.");
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
function assertX402MatchesExpected(paymentRequired, option, expected) {
|
|
384
|
-
assertExpectedShape(expected);
|
|
385
|
-
const headerResource = option.resource ?? paymentRequired.resource.url;
|
|
386
|
-
if (headerResource !== expected.resourceUrl) {
|
|
387
|
-
throw new sdk.HavenSigningError("x402 payment_required resource does not match the funded intent.");
|
|
388
|
-
}
|
|
389
|
-
if (!sameAddress(option.payTo, expected.merchantTo)) {
|
|
390
|
-
throw new sdk.HavenSigningError("x402 merchant recipient does not match the funded intent.");
|
|
391
|
-
}
|
|
392
|
-
if (sdk.x402AuthorizationAmount(option) !== expected.amount) {
|
|
393
|
-
throw new sdk.HavenSigningError("x402 amount does not match the funded intent.");
|
|
394
|
-
}
|
|
395
|
-
if (!sameAddress(option.asset, expected.asset)) {
|
|
396
|
-
throw new sdk.HavenSigningError("x402 asset does not match the funded intent.");
|
|
397
|
-
}
|
|
398
|
-
if (option.network !== expected.network) {
|
|
399
|
-
throw new sdk.HavenSigningError("x402 network does not match the funded intent.");
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
function assertExpectedShape(expected) {
|
|
403
|
-
if (!expected || typeof expected !== "object") {
|
|
404
|
-
throw new sdk.HavenSigningError("x402 expected funding context is required before signing a merchant header.");
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
var SUPPORTED_X402_EXPECTED_VERSIONS = [1, 2, 3];
|
|
408
|
-
var SUPPORTED_SWEEP_BINDING_VERSIONS = [1];
|
|
409
|
-
function assertSupportedBindingVersion(received, supported, context) {
|
|
410
|
-
if (supported.includes(received)) return;
|
|
411
|
-
const highest = Math.max(...supported);
|
|
412
|
-
const outOfDate = received > highest;
|
|
413
|
-
const code = context === "x402 expected context" ? sdk.SignerRefusalCode.UnsupportedExpectedContextVersion : sdk.SignerRefusalCode.UnsupportedSweepBindingVersion;
|
|
414
|
-
const ceiling = outOfDate ? `This signer is out of date: it supports ${context} versions up to ${highest}, and Haven sent version ${received}. Update @haven_ai/signer \u2014 rerun the Haven connector (\`${sdk.connectorRerunCommand()}\`), which reinstalls the pinned MCP runtime.` : `Unsupported ${context} version ${received}: this signer supports ${supported.join(", ")}.`;
|
|
415
|
-
const fallback = outOfDate ? sdk.SIGNER_UPDATE_FALLBACK : `This ${context} version (${received}) is older than what this signer enforces (${supported.join(", ")}) \u2014 updating @haven_ai/signer will not restore it. Nothing was signed or spent; stop and tell the user rather than retrying.`;
|
|
416
|
-
throw new sdk.HavenUnsupportedSignerVersionError(
|
|
417
|
-
`${ceiling} Nothing was signed. Do not rewrite the version field to a supported value: it is part of the Haven-signed binding message, so changing it invalidates the signature and would misrepresent what Haven declared.`,
|
|
418
|
-
code,
|
|
419
|
-
supported,
|
|
420
|
-
received,
|
|
421
|
-
fallback
|
|
422
|
-
);
|
|
423
|
-
}
|
|
424
|
-
function assertExpectedBinding(payloadHash, expected, trustedSigner, mode = "hash") {
|
|
425
|
-
assertExpectedShape(expected);
|
|
426
|
-
if (!trustedSigner) {
|
|
427
|
-
throw new sdk.HavenSigningError(
|
|
428
|
-
"x402 expected-context verifier is not configured. Set HAVEN_X402_BINDING_SIGNER before signing x402 funding hashes."
|
|
495
|
+
const purpose = body.purpose;
|
|
496
|
+
const typedData = body.typed_data;
|
|
497
|
+
const version = body.task_sign_context_version;
|
|
498
|
+
const expected = body.expected;
|
|
499
|
+
const malformed = (detail) => {
|
|
500
|
+
throw new HavenSignContextError(
|
|
501
|
+
`The Haven task-budget sign-context response for ${taskBudgetId} is malformed: ${detail}.`,
|
|
502
|
+
"SIGN_CONTEXT_MALFORMED",
|
|
503
|
+
void 0,
|
|
504
|
+
taskBudgetId,
|
|
505
|
+
"task"
|
|
429
506
|
);
|
|
430
|
-
}
|
|
431
|
-
if (
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
507
|
+
};
|
|
508
|
+
if (typeof version !== "number" || !SUPPORTED_TASK_SIGN_CONTEXT_VERSIONS.includes(version)) {
|
|
509
|
+
throw new HavenSignContextError(
|
|
510
|
+
`The Haven task-budget sign-context response is version ${String(version)}, which this signer does not support (supported: ${SUPPORTED_TASK_SIGN_CONTEXT_VERSIONS.join(", ")}). Update @haven_ai/signer.`,
|
|
511
|
+
"SIGN_CONTEXT_MALFORMED",
|
|
512
|
+
void 0,
|
|
513
|
+
taskBudgetId,
|
|
514
|
+
"task"
|
|
436
515
|
);
|
|
437
516
|
}
|
|
438
|
-
if (
|
|
439
|
-
|
|
517
|
+
if (!typedData || typeof typedData !== "object") return malformed("missing typed_data");
|
|
518
|
+
if (!expected || typeof expected !== "object") return malformed("missing expected");
|
|
519
|
+
const e = expected;
|
|
520
|
+
if (purpose === "open") {
|
|
521
|
+
if (typeof e.delegate_account !== "string" || typeof e.chain_id !== "number" || typeof e.token_address !== "string" || typeof e.max_amount_atomic !== "string" || e.recipient_address !== null && typeof e.recipient_address !== "string" || typeof e.expires_at !== "number" || typeof e.parent_delegation_hash !== "string") {
|
|
522
|
+
return malformed("expected is missing a required field for purpose open");
|
|
523
|
+
}
|
|
524
|
+
return {
|
|
525
|
+
purpose: "open",
|
|
526
|
+
taskBudgetId: String(body.task_budget_id ?? taskBudgetId),
|
|
527
|
+
typedData,
|
|
528
|
+
expected: {
|
|
529
|
+
delegateAccount: e.delegate_account,
|
|
530
|
+
chainId: e.chain_id,
|
|
531
|
+
tokenAddress: e.token_address,
|
|
532
|
+
maxAmountAtomic: e.max_amount_atomic,
|
|
533
|
+
recipientAddress: e.recipient_address ?? null,
|
|
534
|
+
expiresAt: e.expires_at,
|
|
535
|
+
parentDelegationHash: e.parent_delegation_hash
|
|
536
|
+
}
|
|
537
|
+
};
|
|
440
538
|
}
|
|
441
|
-
if (
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
)
|
|
539
|
+
if (purpose === "close") {
|
|
540
|
+
const userOpHash = body.user_op_hash;
|
|
541
|
+
if (typeof userOpHash !== "string") return malformed("missing user_op_hash for purpose close");
|
|
542
|
+
if (typeof e.delegate_account !== "string" || typeof e.chain_id !== "number" || typeof e.delegation_hash !== "string") {
|
|
543
|
+
return malformed("expected is missing a required field for purpose close");
|
|
544
|
+
}
|
|
545
|
+
return {
|
|
546
|
+
purpose: "close",
|
|
547
|
+
taskBudgetId: String(body.task_budget_id ?? taskBudgetId),
|
|
548
|
+
typedData,
|
|
549
|
+
userOpHash,
|
|
550
|
+
expected: {
|
|
551
|
+
delegateAccount: e.delegate_account,
|
|
552
|
+
chainId: e.chain_id,
|
|
553
|
+
delegationHash: e.delegation_hash
|
|
554
|
+
}
|
|
555
|
+
};
|
|
445
556
|
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
557
|
+
return malformed(`unknown purpose '${String(purpose)}'`);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// src/tools.ts
|
|
561
|
+
var USEROP_BINDING_MISMATCH = "USEROP_BINDING_MISMATCH";
|
|
562
|
+
var HavenUserOpBindingRefusedError = class extends edge.HavenSigningError {
|
|
563
|
+
next_action = edge.AgentPaymentNextAction.StopAndTellUser;
|
|
564
|
+
step;
|
|
565
|
+
/** `message`: the SDK's `HavenUserOpBindingError.message` verbatim — it already names the mismatch and the remedy. */
|
|
566
|
+
constructor(message) {
|
|
567
|
+
super(message);
|
|
568
|
+
this.code = USEROP_BINDING_MISMATCH;
|
|
569
|
+
this.name = "HavenUserOpBindingRefusedError";
|
|
570
|
+
this.step = signerRefusalStep({
|
|
571
|
+
nextAction: edge.AgentPaymentNextAction.StopAndTellUser,
|
|
572
|
+
nextTool: null,
|
|
573
|
+
nextToolOmittedReason: "call haven_sign again with payment_id alone (preferred), or with typed_data copied unchanged from the payment result"
|
|
574
|
+
});
|
|
450
575
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
});
|
|
464
|
-
const expectedVersion = expected.payerDelegate ? 3 : expected.typedDataHash ? 2 : 1;
|
|
465
|
-
if (expected.auth?.version !== expectedVersion || expected.auth.message !== message) {
|
|
466
|
-
throw new sdk.HavenSigningError("x402 expected context authentication message is invalid.");
|
|
576
|
+
};
|
|
577
|
+
var HavenTypedDataNotAllowedError = class extends edge.HavenTypedDataRefusedError {
|
|
578
|
+
next_action = edge.AgentPaymentNextAction.StopAndTellUser;
|
|
579
|
+
step;
|
|
580
|
+
constructor(message) {
|
|
581
|
+
super(message);
|
|
582
|
+
this.name = "HavenTypedDataNotAllowedError";
|
|
583
|
+
this.step = signerRefusalStep({
|
|
584
|
+
nextAction: edge.AgentPaymentNextAction.StopAndTellUser,
|
|
585
|
+
nextTool: null,
|
|
586
|
+
nextToolOmittedReason: "sign only Haven-prepared payloads: call haven_sign with payment_id (preferred), or pass typed_data / typed_data_b64 copied unchanged from a haven_send / haven_pay result, or x402_expected from a Haven quote"
|
|
587
|
+
});
|
|
467
588
|
}
|
|
468
|
-
|
|
469
|
-
|
|
589
|
+
};
|
|
590
|
+
function assertBoundDirectPaymentUserOp(typedData, delegateAddress) {
|
|
591
|
+
try {
|
|
592
|
+
edge.assertBoundDirectPaymentUserOp(typedData, delegateAddress);
|
|
593
|
+
} catch (err) {
|
|
594
|
+
if (err instanceof edge.HavenTypedDataRefusedError && !(err instanceof HavenTypedDataNotAllowedError)) {
|
|
595
|
+
throw new HavenTypedDataNotAllowedError(err.message);
|
|
596
|
+
}
|
|
597
|
+
throw err;
|
|
470
598
|
}
|
|
471
|
-
if (!sdk.verifySignature(viem.hashMessage(message), expected.auth.signature, trustedSigner)) {
|
|
472
|
-
throw new sdk.HavenSigningError("x402 expected context signature could not be verified.");
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
function assertPayerMatchesDelegate(expected, delegateAddress, localAgentId) {
|
|
476
|
-
if (!expected.payerDelegate) return;
|
|
477
|
-
if (sameAddress(expected.payerDelegate, delegateAddress)) return;
|
|
478
|
-
const quoteAgent = expected.payerAgentId ?? "unknown";
|
|
479
|
-
const localAgent = localAgentId ?? "unknown";
|
|
480
|
-
throw new sdk.HavenSigningError(
|
|
481
|
-
`This quote belongs to a DIFFERENT agent: the quote was created for agent ${quoteAgent} (delegate ${expected.payerDelegate}), but this signer holds agent ${localAgent} (delegate ${delegateAddress}). A long-lived host is holding stale credentials \u2014 its session authenticates as the old agent while the signer on disk belongs to the new one. Nothing was signed. Restart the host so it re-reads its wiring, then re-quote.`
|
|
482
|
-
);
|
|
483
|
-
}
|
|
484
|
-
function assertX402PaymentWindowOpen(expected) {
|
|
485
|
-
if (!expected.expiresAt) return;
|
|
486
|
-
const expiresAtMs = Date.parse(expected.expiresAt);
|
|
487
|
-
if (Number.isNaN(expiresAtMs)) {
|
|
488
|
-
throw new sdk.HavenSigningError("x402 expected context expiresAt is not a valid ISO timestamp.");
|
|
489
|
-
}
|
|
490
|
-
if (expiresAtMs <= Date.now()) {
|
|
491
|
-
throw new sdk.HavenError(
|
|
492
|
-
"The x402 payment window expired before the merchant header could be signed. Re-quote with haven_pay_mcp_tool using the same idempotency_key before trying again.",
|
|
493
|
-
sdk.AgentPaymentFailureCode.PaymentWindowExpired,
|
|
494
|
-
410,
|
|
495
|
-
expected.paymentId
|
|
496
|
-
);
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
function sameAddress(a, b) {
|
|
500
|
-
return a.toLowerCase() === b.toLowerCase();
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
// src/capabilities.ts
|
|
504
|
-
var SIGNER_CAPABILITY_KEY = "haven/signer-compatibility";
|
|
505
|
-
function signerCompatibility() {
|
|
506
|
-
return {
|
|
507
|
-
x402_expected_context_versions: [...SUPPORTED_X402_EXPECTED_VERSIONS],
|
|
508
|
-
sweep_binding_versions: [...SUPPORTED_SWEEP_BINDING_VERSIONS]
|
|
509
|
-
};
|
|
510
|
-
}
|
|
511
|
-
function signerCapabilityAdvertisement() {
|
|
512
|
-
return { experimental: { [SIGNER_CAPABILITY_KEY]: signerCompatibility() } };
|
|
513
|
-
}
|
|
514
|
-
function signerInstructions() {
|
|
515
|
-
const compatibility = signerCompatibility();
|
|
516
|
-
return [
|
|
517
|
-
"Haven edge signer: sign-only tools bound to the local delegate key. It never emits the",
|
|
518
|
-
"key. Its one network capability is an authenticated READ of a signing context from",
|
|
519
|
-
"Haven by payment_id \u2014 pass payment_id to haven_sign / haven_sign_x402 (preferred for",
|
|
520
|
-
"delegation-rail x402) instead of relaying bulky typed-data payloads yourself.",
|
|
521
|
-
"",
|
|
522
|
-
"Version compatibility (check this BEFORE signing, not after):",
|
|
523
|
-
`- x402 expected-context versions supported: ${compatibility.x402_expected_context_versions.join(", ")}`,
|
|
524
|
-
`- sweep authorization binding versions supported: ${compatibility.sweep_binding_versions.join(", ")}`,
|
|
525
|
-
"",
|
|
526
|
-
"Haven quote and prepare results report the expected-context version they will emit",
|
|
527
|
-
"(signer_compatibility.x402_expected_context_version). If that version is not in the list",
|
|
528
|
-
"above, this signer is out of date: STOP before signing, and tell the user to update",
|
|
529
|
-
`@haven_ai/signer by rerunning \`${sdk.connectorRerunCommand()}\`, which reinstalls the pinned`,
|
|
530
|
-
"MCP runtime. Do not edit the version field to a supported value \u2014 it is part of the",
|
|
531
|
-
"Haven-signed binding message, so changing it invalidates the signature.",
|
|
532
|
-
"",
|
|
533
|
-
"A version-mismatch refusal from haven_sign / haven_sign_x402 / haven_sign_sweep_delegate is",
|
|
534
|
-
"machine-readable, not just prose: it carries code, supported_versions, received_version, and",
|
|
535
|
-
"fallback fields alongside the message, so you can branch on it directly. Every signer",
|
|
536
|
-
"refusal also carries the next-step family: next_tool_name + next_tool_server_role when a",
|
|
537
|
-
"hosted tool follows (resolve the role against your own server names), else",
|
|
538
|
-
"next_tool_omitted_reason saying why not."
|
|
539
|
-
].join("\n");
|
|
540
|
-
}
|
|
541
|
-
var SIGNER_HOSTED_HANDOFF_SHAPES = {
|
|
542
|
-
haven_get_payment_status: { payment_id: zod.z.string().min(1) }
|
|
543
|
-
};
|
|
544
|
-
function target(shape) {
|
|
545
|
-
const schema = zod.z.object(shape).strict();
|
|
546
|
-
return {
|
|
547
|
-
role: "hosted",
|
|
548
|
-
validate: (input) => {
|
|
549
|
-
const r = schema.safeParse(input ?? {});
|
|
550
|
-
return r.success ? null : r.error.errors.map((e) => `${e.path.join(".") || "(root)"}: ${e.message}`).join("; ");
|
|
551
|
-
}
|
|
552
|
-
};
|
|
553
|
-
}
|
|
554
|
-
var SIGNER_NEXT_STEP_TARGETS = {
|
|
555
|
-
haven_get_payment_status: target(SIGNER_HOSTED_HANDOFF_SHAPES.haven_get_payment_status)
|
|
556
|
-
};
|
|
557
|
-
var nextStep = sdk.createNextStepBuilder(SIGNER_NEXT_STEP_TARGETS);
|
|
558
|
-
function signerRefusalStep(input) {
|
|
559
|
-
return nextStep({ ...input, safeToContinue: false, reason: "" });
|
|
560
|
-
}
|
|
561
|
-
function nextStepWireFields(step) {
|
|
562
|
-
return {
|
|
563
|
-
...step.next_tool ? { next_tool: step.next_tool } : {},
|
|
564
|
-
...step.next_tool_server ? { next_tool_server: step.next_tool_server } : {},
|
|
565
|
-
...step.next_tool_name ? { next_tool_name: step.next_tool_name } : {},
|
|
566
|
-
...step.next_tool_server_role ? { next_tool_server_role: step.next_tool_server_role } : {},
|
|
567
|
-
...step.next_arguments ? { next_arguments: step.next_arguments } : {},
|
|
568
|
-
...step.next_tool_omitted_reason ? { next_tool_omitted_reason: step.next_tool_omitted_reason } : {}
|
|
569
|
-
};
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
// src/sign-context.ts
|
|
573
|
-
var HavenSignContextError = class extends sdk.HavenSigningError {
|
|
574
|
-
/**
|
|
575
|
-
* #3001 / #3010 review: the recovery path, when signing OTHER bytes is
|
|
576
|
-
* actually a remedy — a transport failure (timeout, unreachable) or a
|
|
577
|
-
* body this signer could not read. `typed_data_b64` is NOT in the default
|
|
578
|
-
* quote result since #1272: obtain it by re-running the SAME quote tool
|
|
579
|
-
* with the SAME idempotency_key plus `include_signing_payload: true`.
|
|
580
|
-
* Absent on a backend REFUSAL: an expired, executed or unsignable intent
|
|
581
|
-
* cannot be rescued by re-signing its bytes.
|
|
582
|
-
*/
|
|
583
|
-
fallback;
|
|
584
|
-
/**
|
|
585
|
-
* #3001: `AgentPaymentNextAction` values the signer already emits — the
|
|
586
|
-
* version-mismatch refusal's `stop_and_tell_user` for the classes where
|
|
587
|
-
* retrying the same call cannot help, and `payment_window_expired` (with
|
|
588
|
-
* `retry_with_new_quote`) for the backend's 410 `expired`, exactly as the
|
|
589
|
-
* plain `HavenError` branch already does for that code.
|
|
590
|
-
*/
|
|
591
|
-
next_action;
|
|
592
|
-
/**
|
|
593
|
-
* #3103 (epic #3105, decisions 1 and 3): the typed next step beside the
|
|
594
|
-
* action. A refusal Haven made (`SIGN_CONTEXT_REFUSED`, not expired) names
|
|
595
|
-
* the hosted status read with the payment id; a transport failure or a
|
|
596
|
-
* malformed body names no tool — the remedy is re-running the SAME quote
|
|
597
|
-
* tool with `include_signing_payload: true` — and an expired window names
|
|
598
|
-
* none either (which quote tool depends on the flow). Never null: a step
|
|
599
|
-
* with no tool carries `next_tool_omitted_reason`. Additive: the class is
|
|
600
|
-
* not exported from the package index — the published surface is the
|
|
601
|
-
* `ToolFailure` envelope `tools.ts` builds from these fields — and every
|
|
602
|
-
* constructor call site is in this file.
|
|
603
|
-
*/
|
|
604
|
-
next_tool;
|
|
605
|
-
next_tool_server;
|
|
606
|
-
next_tool_name;
|
|
607
|
-
next_tool_server_role;
|
|
608
|
-
next_arguments;
|
|
609
|
-
next_tool_omitted_reason;
|
|
610
|
-
retry_with_new_quote;
|
|
611
|
-
/** Present only for `SIGN_CONTEXT_REFUSED` (the backend's HTTP status). */
|
|
612
|
-
http_status;
|
|
613
|
-
/** Present only for `SIGN_CONTEXT_REFUSED`: the backend's own `error_code`. */
|
|
614
|
-
backend_error_code;
|
|
615
|
-
constructor(message, code, refusal, paymentId) {
|
|
616
|
-
super(message);
|
|
617
|
-
this.code = code;
|
|
618
|
-
this.name = "HavenSignContextError";
|
|
619
|
-
let step;
|
|
620
|
-
if (code === "SIGN_CONTEXT_REFUSED") {
|
|
621
|
-
this.http_status = refusal?.httpStatus;
|
|
622
|
-
this.backend_error_code = refusal?.errorCode;
|
|
623
|
-
if (refusal?.httpStatus === 410 || refusal?.errorCode === "expired") {
|
|
624
|
-
this.next_action = sdk.AgentPaymentNextAction.PaymentWindowExpired;
|
|
625
|
-
this.retry_with_new_quote = true;
|
|
626
|
-
step = signerRefusalStep({
|
|
627
|
-
nextAction: sdk.AgentPaymentNextAction.PaymentWindowExpired,
|
|
628
|
-
nextTool: null,
|
|
629
|
-
nextToolOmittedReason: "re-run the hosted quote tool you called with the same idempotency_key; which one depends on the flow"
|
|
630
|
-
});
|
|
631
|
-
} else {
|
|
632
|
-
this.next_action = sdk.AgentPaymentNextAction.StopAndTellUser;
|
|
633
|
-
step = signerRefusalStep({
|
|
634
|
-
nextAction: sdk.AgentPaymentNextAction.StopAndTellUser,
|
|
635
|
-
nextTool: "haven_get_payment_status",
|
|
636
|
-
nextArguments: { payment_id: paymentId }
|
|
637
|
-
});
|
|
638
|
-
}
|
|
639
|
-
} else {
|
|
640
|
-
this.fallback = "typed_data_b64";
|
|
641
|
-
this.next_action = sdk.AgentPaymentNextAction.StopAndTellUser;
|
|
642
|
-
step = signerRefusalStep({
|
|
643
|
-
nextAction: sdk.AgentPaymentNextAction.StopAndTellUser,
|
|
644
|
-
nextTool: null,
|
|
645
|
-
nextToolOmittedReason: "re-run the SAME hosted quote tool with the same idempotency_key and include_signing_payload: true, then pass its typed_data_b64 to this signer"
|
|
646
|
-
});
|
|
647
|
-
}
|
|
648
|
-
Object.assign(this, nextStepWireFields(step));
|
|
649
|
-
}
|
|
650
|
-
};
|
|
651
|
-
async function loadHavenIdentity(credentialsPath) {
|
|
652
|
-
if (!credentialsPath) return null;
|
|
653
|
-
try {
|
|
654
|
-
const raw = JSON.parse(
|
|
655
|
-
await promises.readFile(path.join(path.dirname(credentialsPath), "identity.json"), "utf8")
|
|
656
|
-
);
|
|
657
|
-
const apiKey = typeof raw.api_key === "string" ? raw.api_key : void 0;
|
|
658
|
-
const apiUrl = typeof raw.api_url === "string" ? raw.api_url : void 0;
|
|
659
|
-
if (!apiKey || !apiUrl) return null;
|
|
660
|
-
return { apiKey, apiUrl: apiUrl.replace(/\/+$/, "") };
|
|
661
|
-
} catch {
|
|
662
|
-
return null;
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
var SIGN_CONTEXT_TIMEOUT_MS = 15e3;
|
|
666
|
-
async function fetchX402SignContext(identity, paymentId, fetchImpl = fetch, timeoutMs = SIGN_CONTEXT_TIMEOUT_MS) {
|
|
667
|
-
let response;
|
|
668
|
-
try {
|
|
669
|
-
response = await fetchImpl(
|
|
670
|
-
`${identity.apiUrl}/x402/${encodeURIComponent(paymentId)}/sign-context`,
|
|
671
|
-
{
|
|
672
|
-
headers: { Authorization: `Bearer ${identity.apiKey}` },
|
|
673
|
-
signal: AbortSignal.timeout(timeoutMs)
|
|
674
|
-
}
|
|
675
|
-
);
|
|
676
|
-
} catch (err) {
|
|
677
|
-
const timedOut = err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
|
|
678
|
-
throw new HavenSignContextError(
|
|
679
|
-
timedOut ? `Haven did not answer the signing-context fetch for ${paymentId} within ${timeoutMs} ms. Retry, or pass typed_data_b64 from the quote result instead.` : `Could not reach Haven to fetch the signing context for ${paymentId}: ${err instanceof Error ? err.message : String(err)}. Retry, or pass typed_data_b64 from the quote result instead.`,
|
|
680
|
-
timedOut ? "SIGN_CONTEXT_TIMEOUT" : "SIGN_CONTEXT_UNREACHABLE",
|
|
681
|
-
void 0,
|
|
682
|
-
paymentId
|
|
683
|
-
);
|
|
684
|
-
}
|
|
685
|
-
let body;
|
|
686
|
-
try {
|
|
687
|
-
body = await response.json();
|
|
688
|
-
} catch (err) {
|
|
689
|
-
if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
|
|
690
|
-
throw new HavenSignContextError(
|
|
691
|
-
`Haven did not finish sending the signing context for ${paymentId} within ${timeoutMs} ms. Retry, or pass typed_data_b64 from the quote result instead.`,
|
|
692
|
-
"SIGN_CONTEXT_TIMEOUT",
|
|
693
|
-
void 0,
|
|
694
|
-
paymentId
|
|
695
|
-
);
|
|
696
|
-
}
|
|
697
|
-
body = {};
|
|
698
|
-
}
|
|
699
|
-
if (!response.ok) {
|
|
700
|
-
const detail = typeof body.error === "string" ? body.error : `HTTP ${response.status}`;
|
|
701
|
-
throw new HavenSignContextError(
|
|
702
|
-
`Haven refused the signing-context fetch for ${paymentId}: ${detail}` + (response.status === 404 ? " \u2014 check the payment_id came from this agent\u2019s own quote." : response.status === 410 ? " Re-run the quote with the same idempotency key, then sign the fresh payment_id." : ""),
|
|
703
|
-
"SIGN_CONTEXT_REFUSED",
|
|
704
|
-
{ httpStatus: response.status, errorCode: typeof body.error_code === "string" ? body.error_code : void 0 },
|
|
705
|
-
paymentId
|
|
706
|
-
);
|
|
707
|
-
}
|
|
708
|
-
const signData = body.sign_data;
|
|
709
|
-
const x402Expected = body.x402_expected;
|
|
710
|
-
if (!signData || typeof signData.hash !== "string" || !signData.typed_data || typeof signData.typed_data !== "object" || !x402Expected) {
|
|
711
|
-
throw new HavenSignContextError(
|
|
712
|
-
"The Haven sign-context response is missing sign_data.typed_data or x402_expected \u2014 the backend may predate #1263. Pass typed_data_b64 from the quote result instead.",
|
|
713
|
-
"SIGN_CONTEXT_MALFORMED",
|
|
714
|
-
void 0,
|
|
715
|
-
paymentId
|
|
716
|
-
);
|
|
717
|
-
}
|
|
718
|
-
const paymentRequired = body.payment_required;
|
|
719
|
-
return {
|
|
720
|
-
paymentId: String(body.payment_id ?? paymentId),
|
|
721
|
-
payloadHash: signData.hash,
|
|
722
|
-
typedData: signData.typed_data,
|
|
723
|
-
x402Expected,
|
|
724
|
-
paymentRequired: paymentRequired && typeof paymentRequired === "object" && !Array.isArray(paymentRequired) ? paymentRequired : null
|
|
725
|
-
};
|
|
726
599
|
}
|
|
727
|
-
|
|
728
|
-
// src/tools.ts
|
|
729
600
|
var sweepAuthorizationSchema = v3.z.object({
|
|
730
601
|
from: v3.z.string().regex(/^0x[0-9a-fA-F]{40}$/, "from must be a 0x address"),
|
|
731
602
|
to: v3.z.string().regex(/^0x[0-9a-fA-F]{40}$/, "to must be a 0x address"),
|
|
@@ -745,7 +616,7 @@ var sweepExpectedAuthSchema = v3.z.object({
|
|
|
745
616
|
});
|
|
746
617
|
var x402ExpectedShape = {
|
|
747
618
|
payment_id: v3.z.string().min(1),
|
|
748
|
-
payload_hash: v3.z.string().regex(/^0x[0-9a-fA-F]
|
|
619
|
+
payload_hash: v3.z.string().regex(/^0x[0-9a-fA-F]{64}$/, "payload_hash must be a 0x-prefixed 32-byte hex string"),
|
|
749
620
|
resource_url: v3.z.string().url(),
|
|
750
621
|
merchant_to: v3.z.string().min(1),
|
|
751
622
|
amount: v3.z.string().min(1),
|
|
@@ -759,7 +630,7 @@ var x402ExpectedShape = {
|
|
|
759
630
|
// #1138: present on a delegation-rail (v2) context. It commits to the EIP-712
|
|
760
631
|
// typed data the account validates; the signer refuses to raw-sign the bare
|
|
761
632
|
// hash when it is present, and refuses to sign typed data when it is absent.
|
|
762
|
-
typed_data_hash: v3.z.string().regex(/^0x[0-9a-fA-F]
|
|
633
|
+
typed_data_hash: v3.z.string().regex(/^0x[0-9a-fA-F]{64}$/, "typed_data_hash must be a 0x-prefixed 32-byte hex string").optional(),
|
|
763
634
|
// #1690: present on a v3 context. The delegate this quote was created FOR —
|
|
764
635
|
// the signer refuses to sign when it is not its own. Inside the Haven-signed
|
|
765
636
|
// message, so stripping it here would only break the binding signature.
|
|
@@ -791,7 +662,7 @@ var toolSchemas = {
|
|
|
791
662
|
payment_id: v3.z.string().min(1).optional(),
|
|
792
663
|
// The unsigned hash from haven_pay / haven_pay_x402_quote (payload_hash).
|
|
793
664
|
// Optional when payment_id is supplied (the fetch carries it).
|
|
794
|
-
payload_hash: v3.z.string().regex(/^0x[0-9a-fA-F]
|
|
665
|
+
payload_hash: v3.z.string().regex(/^0x[0-9a-fA-F]{64}$/, "payload_hash must be a 0x-prefixed 32-byte hex string").optional(),
|
|
795
666
|
// Pass x402.expected from hosted haven_pay_x402_quote when this hash funds
|
|
796
667
|
// a standard x402 merchant retry. The signer records it locally and returns
|
|
797
668
|
// an opaque x402_binding for the later header-signing step.
|
|
@@ -807,7 +678,14 @@ var toolSchemas = {
|
|
|
807
678
|
// Bounded: a realistic redemption payload is ~10KB encoded; 256KB is
|
|
808
679
|
// generous headroom while keeping the offline signer from materializing
|
|
809
680
|
// arbitrarily large caller input.
|
|
810
|
-
typed_data_b64: v3.z.string().min(1).max(262144).optional()
|
|
681
|
+
typed_data_b64: v3.z.string().min(1).max(262144).optional(),
|
|
682
|
+
// #3329: sign a task budget's own child delegation (purpose `open`) or
|
|
683
|
+
// its early-close UserOp (purpose `close`) — the signer fetches the
|
|
684
|
+
// exact typed data and expectation itself, exactly like payment_id.
|
|
685
|
+
// Mutually exclusive with payment_id / payload_hash (checked in the
|
|
686
|
+
// handler, since Zod's object-level refine on a raw shape used by
|
|
687
|
+
// several call sites is not the shape this schema is composed as).
|
|
688
|
+
task_budget_id: v3.z.string().min(1).optional()
|
|
811
689
|
},
|
|
812
690
|
haven_x402_sign_header: {
|
|
813
691
|
// The parsed HTTP 402 PaymentRequired from the merchant. Typed as an object
|
|
@@ -823,7 +701,7 @@ var toolSchemas = {
|
|
|
823
701
|
payment_id: v3.z.string().min(1).optional(),
|
|
824
702
|
// One-shot x402 signing: funding hash + merchant header in one local call.
|
|
825
703
|
// Both optional when payment_id is supplied (the fetch carries them).
|
|
826
|
-
payload_hash: v3.z.string().regex(/^0x[0-9a-fA-F]
|
|
704
|
+
payload_hash: v3.z.string().regex(/^0x[0-9a-fA-F]{64}$/, "payload_hash must be a 0x-prefixed 32-byte hex string").optional(),
|
|
827
705
|
x402_expected: x402ExpectedSchema.optional(),
|
|
828
706
|
// #1355: optional when payment_id is supplied — the signer's context fetch
|
|
829
707
|
// carries the stored 402 PaymentRequired, so `{ payment_id }` alone is the
|
|
@@ -839,17 +717,29 @@ var toolSchemas = {
|
|
|
839
717
|
// #3101: keys survive on the type (see the hosted server's contracts.ts).
|
|
840
718
|
};
|
|
841
719
|
var SIGN_DESCRIPTION = [
|
|
842
|
-
"Sign an unsigned Haven payment
|
|
843
|
-
"this process. Pass the payload_hash
|
|
720
|
+
"Sign an unsigned Haven payment with the local delegate key. The delegate key never leaves",
|
|
721
|
+
"this process. Pass payment_id (preferred), or the payload_hash from haven_pay / haven_pay_x402_quote",
|
|
722
|
+
"TOGETHER WITH typed_data / typed_data_b64 or x402_expected \u2014 never a payload_hash alone.",
|
|
844
723
|
"For x402, also pass x402_expected from haven_pay_x402_quote; the signer records it locally",
|
|
845
724
|
"and returns { signature, x402_binding }. x402_expected includes expires_at; sign before that",
|
|
846
725
|
"window closes. DELEGATION-RAIL x402 accounts (#1263): pass payment_id ALONE (preferred) \u2014 this",
|
|
847
726
|
"signer fetches the exact signing payload and expected context from Haven itself, so nothing",
|
|
848
|
-
"bulky ever crosses your context.
|
|
849
|
-
"
|
|
727
|
+
"bulky ever crosses your context. DIRECT payments (#3271, haven_send / haven_pay): payment_id",
|
|
728
|
+
"ALONE also works here \u2014 the signer fetches the exact PackedUserOperation typed data and its",
|
|
729
|
+
"payload_hash from Haven and checks them against each other before signing, so a payload corrupted",
|
|
730
|
+
"in transit is refused (USEROP_BINDING_MISMATCH) instead of producing a bad signature. Fallback for",
|
|
731
|
+
"either flow: pass typed_data_b64 through UNCHANGED (never re-type the nested typed_data JSON); the",
|
|
732
|
+
"account validates that EIP-712 payload, not payload_hash. On a direct payment the same binding",
|
|
733
|
+
"check runs on the relayed payload too. This tool signs ONLY Haven-prepared payloads (#3272): a",
|
|
734
|
+
"direct-payment UserOp from your own account that redeems a delegation granted to it, or an x402",
|
|
735
|
+
"intent against a Haven-signed context \u2014 never an arbitrary typed_data payload, however it is shaped.",
|
|
850
736
|
"Next: call mcp__haven__haven_submit with signature, then pass x402_binding",
|
|
851
|
-
"to mcp__haven-signer__haven_x402_sign_header.
|
|
852
|
-
"
|
|
737
|
+
"to mcp__haven-signer__haven_x402_sign_header. A bare payload_hash with no payment_id, typed_data",
|
|
738
|
+
"or x402_expected is REFUSED (BARE_HASH_REFUSED): a hash carries nothing this signer can verify.",
|
|
739
|
+
"TASK BUDGETS (#3329): pass task_budget_id ALONE \u2014 mutually exclusive with payment_id and",
|
|
740
|
+
"payload_hash \u2014 to sign a task budget\u2019s own child delegation (opening it) or its early-close",
|
|
741
|
+
"UserOp; this signer fetches the exact typed data itself. Result then carries next_tool_name",
|
|
742
|
+
"(haven_submit) and next_arguments { task_budget_id, signature } instead of x402_binding."
|
|
853
743
|
].join(" ");
|
|
854
744
|
var X402_SIGN_HEADER_DESCRIPTION = [
|
|
855
745
|
"Build and sign the EIP-3009 merchant payment header for the merchant leg of an x402 payment.",
|
|
@@ -867,7 +757,7 @@ var X402_SIGN_HEADER_DESCRIPTION = [
|
|
|
867
757
|
"the funding tx has a confirmed status). Next for paid MCP tools: call mcp__haven__haven_complete_mcp_tool."
|
|
868
758
|
].join(" ");
|
|
869
759
|
var SIGN_X402_DESCRIPTION = [
|
|
870
|
-
"One-shot x402 signing for the fast 3-call flow: sign the funding
|
|
760
|
+
"One-shot x402 signing for the fast 3-call flow: sign the funding payload AND build the EIP-3009",
|
|
871
761
|
"merchant payment header in a single local call (equivalent to haven_sign followed by",
|
|
872
762
|
"haven_x402_sign_header). The delegate key never leaves this process. From the haven_pay_mcp_tool",
|
|
873
763
|
"result pass JUST payment_id \u2014 PREFERRED (#1263, #1355): this signer fetches the exact signing",
|
|
@@ -893,12 +783,13 @@ var SIGN_X402_DESCRIPTION = [
|
|
|
893
783
|
].join(" ");
|
|
894
784
|
var SIGN_SWEEP_DELEGATE_DESCRIPTION = [
|
|
895
785
|
"Sign a Haven-prepared gasless USDC sweep that recovers stranded funds from the delegate",
|
|
896
|
-
"wallet back to
|
|
897
|
-
"never broadcasts \u2014 it returns only an EIP-3009 signature that Haven's relayer submits and",
|
|
786
|
+
"wallet back to the agent's account (Haven wallet). The delegate key never leaves this process and this",
|
|
787
|
+
"tool never broadcasts \u2014 it returns only an EIP-3009 signature that Haven's relayer submits and",
|
|
898
788
|
"pays gas for. Pass the authorization and expected_auth returned by the hosted",
|
|
899
|
-
"haven_sweep_delegate tool. The signer verifies Haven authored the authorization
|
|
900
|
-
"
|
|
901
|
-
"
|
|
789
|
+
"haven_sweep_delegate tool. The signer verifies Haven authored the authorization; the",
|
|
790
|
+
"destination is checked against the agent's account (Haven wallet) from the local credential",
|
|
791
|
+
"when the credential carries one, and otherwise rests on Haven's binding signature. Then it",
|
|
792
|
+
"returns { signature } to hand back to mcp__haven__haven_sweep_delegate to complete recovery."
|
|
902
793
|
].join(" ");
|
|
903
794
|
var toolDescriptions = {
|
|
904
795
|
haven_sign: SIGN_DESCRIPTION,
|
|
@@ -906,16 +797,18 @@ var toolDescriptions = {
|
|
|
906
797
|
haven_sign_x402: SIGN_X402_DESCRIPTION,
|
|
907
798
|
haven_sign_sweep_delegate: SIGN_SWEEP_DELEGATE_DESCRIPTION
|
|
908
799
|
};
|
|
909
|
-
async function signFundingLeg(signer, expected,
|
|
910
|
-
|
|
911
|
-
return signer.
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
)
|
|
800
|
+
async function signFundingLeg(signer, expected, typedData) {
|
|
801
|
+
try {
|
|
802
|
+
return await signer.signX402FundingTypedData(typedData, expected);
|
|
803
|
+
} catch (err) {
|
|
804
|
+
if (err instanceof edge.HavenTypedDataRefusedError && !(err instanceof HavenTypedDataNotAllowedError)) {
|
|
805
|
+
throw new HavenTypedDataNotAllowedError(err.message);
|
|
806
|
+
}
|
|
807
|
+
if (err instanceof edge.HavenUserOpBindingError) {
|
|
808
|
+
throw new HavenUserOpBindingRefusedError(err.message);
|
|
809
|
+
}
|
|
810
|
+
throw err;
|
|
917
811
|
}
|
|
918
|
-
return signer.signX402FundingTypedData(typedData, expected);
|
|
919
812
|
}
|
|
920
813
|
function toExpectedX402(raw) {
|
|
921
814
|
return {
|
|
@@ -936,12 +829,15 @@ function toExpectedX402(raw) {
|
|
|
936
829
|
function parseFetchedExpected(fetched) {
|
|
937
830
|
const parsed = v3.z.object(x402ExpectedShape).safeParse(fetched.x402Expected);
|
|
938
831
|
if (!parsed.success) {
|
|
939
|
-
throw new
|
|
832
|
+
throw new edge.HavenSigningError(
|
|
940
833
|
`The Haven sign-context response carried a malformed x402_expected \u2014 the backend may predate #1263. Pass payload_hash + x402_expected from the quote result instead. Underlying: ${parsed.error.issues[0]?.message ?? "schema mismatch"}`
|
|
941
834
|
);
|
|
942
835
|
}
|
|
943
836
|
return parsed.data;
|
|
944
837
|
}
|
|
838
|
+
function clientUpdateField(resolved) {
|
|
839
|
+
return resolved?.ctx.clientUpdate ? { client_update: resolved.ctx.clientUpdate } : {};
|
|
840
|
+
}
|
|
945
841
|
function resolveTypedData(args) {
|
|
946
842
|
if (!args.typed_data_b64) return args.typed_data;
|
|
947
843
|
try {
|
|
@@ -953,372 +849,976 @@ function resolveTypedData(args) {
|
|
|
953
849
|
}
|
|
954
850
|
return decoded;
|
|
955
851
|
} catch (err) {
|
|
956
|
-
throw new
|
|
852
|
+
throw new edge.HavenSigningError(
|
|
957
853
|
`typed_data_b64 did not decode to a JSON object. Pass the exact string returned by the hosted Haven tool, unchanged \u2014 do not re-encode, trim, or reformat it. Underlying error: ${err instanceof Error ? err.message : String(err)}`
|
|
958
854
|
);
|
|
959
855
|
}
|
|
960
856
|
}
|
|
961
857
|
function createToolHandlers(signer, options = {}) {
|
|
962
|
-
|
|
858
|
+
function checkPayloadHashMatch(suppliedHash, fetchedHash, paymentId) {
|
|
859
|
+
if (suppliedHash && suppliedHash.toLowerCase() !== fetchedHash.toLowerCase()) {
|
|
860
|
+
throw new edge.HavenSigningError(
|
|
861
|
+
`The supplied payload_hash does not match the signing context Haven serves for payment ${paymentId}. Pass payment_id alone, or check which quote the hash came from.`
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
async function resolveSignContext(args, opts) {
|
|
963
866
|
if (!args.payment_id) return null;
|
|
964
867
|
const identity = await options.signContext?.loadIdentity() ?? null;
|
|
965
868
|
if (!identity) {
|
|
966
|
-
throw new
|
|
967
|
-
`payment_id signing needs the agent identity (identity.json next to the signer credentials), which this signer could not load. Re-run \`${
|
|
869
|
+
throw new edge.HavenSigningError(
|
|
870
|
+
`payment_id signing needs the agent identity (identity.json next to the signer credentials), which this signer could not load. Re-run \`${edge.connectorRerunCommand()}\` to restore it, or pass typed_data_b64 from the quote result instead.`
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
try {
|
|
874
|
+
const ctx = await fetchX402SignContext(
|
|
875
|
+
identity,
|
|
876
|
+
args.payment_id,
|
|
877
|
+
options.signContext?.fetchImpl,
|
|
878
|
+
void 0,
|
|
879
|
+
options.signContext?.clientIdentity
|
|
880
|
+
);
|
|
881
|
+
checkPayloadHashMatch(args.payload_hash, ctx.payloadHash, args.payment_id);
|
|
882
|
+
return { kind: "x402", ctx };
|
|
883
|
+
} catch (err) {
|
|
884
|
+
if (opts.allowDirectFallback && err instanceof HavenSignContextError && err.http_status === 409 && err.backend_error_code === "sign_context_unavailable") {
|
|
885
|
+
let ctx;
|
|
886
|
+
try {
|
|
887
|
+
ctx = await fetchDirectSignContext(
|
|
888
|
+
identity,
|
|
889
|
+
args.payment_id,
|
|
890
|
+
options.signContext?.fetchImpl,
|
|
891
|
+
void 0,
|
|
892
|
+
options.signContext?.clientIdentity
|
|
893
|
+
);
|
|
894
|
+
} catch (directErr) {
|
|
895
|
+
if (directErr instanceof HavenSignContextError && directErr.http_status === 409 && directErr.backend_error_code === "sign_context_unavailable") {
|
|
896
|
+
throw err;
|
|
897
|
+
}
|
|
898
|
+
throw directErr;
|
|
899
|
+
}
|
|
900
|
+
checkPayloadHashMatch(args.payload_hash, ctx.payloadHash, args.payment_id);
|
|
901
|
+
return { kind: "direct", ctx };
|
|
902
|
+
}
|
|
903
|
+
throw err;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
async function signTaskBudget(taskBudgetId) {
|
|
907
|
+
const identity = await options.signContext?.loadIdentity() ?? null;
|
|
908
|
+
if (!identity) {
|
|
909
|
+
throw new edge.HavenSigningError(
|
|
910
|
+
`task_budget_id signing needs the agent identity (identity.json next to the signer credentials), which this signer could not load. Re-run \`${edge.connectorRerunCommand()}\` to restore it.`
|
|
968
911
|
);
|
|
969
912
|
}
|
|
970
|
-
const ctx = await
|
|
913
|
+
const ctx = await fetchTaskBudgetSignContext(
|
|
971
914
|
identity,
|
|
972
|
-
|
|
973
|
-
options.signContext?.fetchImpl
|
|
915
|
+
taskBudgetId,
|
|
916
|
+
options.signContext?.fetchImpl,
|
|
917
|
+
void 0,
|
|
918
|
+
options.signContext?.clientIdentity
|
|
974
919
|
);
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
920
|
+
let signature;
|
|
921
|
+
if (ctx.purpose === "open") {
|
|
922
|
+
try {
|
|
923
|
+
edge.assertOwnTaskChild(
|
|
924
|
+
ctx.typedData,
|
|
925
|
+
{
|
|
926
|
+
chainId: ctx.expected.chainId,
|
|
927
|
+
tokenAddress: ctx.expected.tokenAddress,
|
|
928
|
+
maxAmountAtomic: ctx.expected.maxAmountAtomic,
|
|
929
|
+
recipientAddress: ctx.expected.recipientAddress,
|
|
930
|
+
expiresAt: ctx.expected.expiresAt,
|
|
931
|
+
parentDelegationHash: ctx.expected.parentDelegationHash
|
|
932
|
+
},
|
|
933
|
+
signer.delegateAddress
|
|
934
|
+
);
|
|
935
|
+
} catch (err) {
|
|
936
|
+
if (err instanceof edge.HavenTypedDataRefusedError) throw err;
|
|
937
|
+
throw new edge.HavenTypedDataRefusedError(err instanceof Error ? err.message : String(err));
|
|
938
|
+
}
|
|
939
|
+
signature = await signer.signDelegationTypedData(ctx.typedData);
|
|
940
|
+
} else {
|
|
941
|
+
try {
|
|
942
|
+
edge.assertUserOpTypedDataBinding(ctx.typedData, ctx.userOpHash);
|
|
943
|
+
} catch (err) {
|
|
944
|
+
if (err instanceof edge.HavenUserOpBindingError) throw new HavenUserOpBindingRefusedError(err.message);
|
|
945
|
+
throw err;
|
|
946
|
+
}
|
|
947
|
+
try {
|
|
948
|
+
edge.assertOwnTaskBudgetCloseUserOp(
|
|
949
|
+
ctx.typedData,
|
|
950
|
+
{ delegationHash: ctx.expected.delegationHash },
|
|
951
|
+
signer.delegateAddress
|
|
952
|
+
);
|
|
953
|
+
} catch (err) {
|
|
954
|
+
if (err instanceof edge.HavenTypedDataRefusedError) throw err;
|
|
955
|
+
throw new edge.HavenTypedDataRefusedError(err instanceof Error ? err.message : String(err));
|
|
956
|
+
}
|
|
957
|
+
signature = await signer.signDelegationTypedData(ctx.typedData);
|
|
958
|
+
}
|
|
959
|
+
await auditSigning("haven_sign", viem.hashTypedData(ctx.typedData));
|
|
960
|
+
return {
|
|
961
|
+
signature,
|
|
962
|
+
task_budget_id: ctx.taskBudgetId,
|
|
963
|
+
purpose: ctx.purpose,
|
|
964
|
+
next_tool_name: "haven_submit",
|
|
965
|
+
next_tool_server_role: "hosted",
|
|
966
|
+
next_arguments: { task_budget_id: ctx.taskBudgetId, signature }
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
return {
|
|
970
|
+
haven_sign: async (input) => runTool(async () => {
|
|
971
|
+
const args = parse("haven_sign", coerceX402Expected(input));
|
|
972
|
+
if (args.task_budget_id) {
|
|
973
|
+
if (args.payment_id || args.payload_hash) {
|
|
974
|
+
throw new v3.z.ZodError([
|
|
975
|
+
{
|
|
976
|
+
code: v3.z.ZodIssueCode.custom,
|
|
977
|
+
path: ["task_budget_id"],
|
|
978
|
+
message: "task_budget_id is mutually exclusive with payment_id / payload_hash \u2014 pass exactly one."
|
|
979
|
+
}
|
|
980
|
+
]);
|
|
981
|
+
}
|
|
982
|
+
return signTaskBudget(args.task_budget_id);
|
|
983
|
+
}
|
|
984
|
+
const resolved = await resolveSignContext(args, { allowDirectFallback: true });
|
|
985
|
+
const typedData = resolved?.ctx.typedData ?? resolveTypedData(args);
|
|
986
|
+
const payloadHash = resolved?.ctx.payloadHash ?? args.payload_hash;
|
|
987
|
+
if (!payloadHash) {
|
|
988
|
+
throw new edge.HavenSigningError(
|
|
989
|
+
"Pass payment_id (preferred for delegation-rail x402 or direct payments) or payload_hash."
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
const expectedRaw = resolved?.kind === "x402" ? parseFetchedExpected(resolved.ctx) : resolved?.kind === "direct" ? null : args.x402_expected;
|
|
993
|
+
const x402Expected = expectedRaw ? toExpectedX402(expectedRaw) : null;
|
|
994
|
+
const result = x402Expected ? await signFundingLeg(signer, x402Expected, typedData) : null;
|
|
995
|
+
if (!result) {
|
|
996
|
+
if (typedData) {
|
|
997
|
+
if (edge.isSettlementChildTypedData(typedData)) {
|
|
998
|
+
throw new edge.HavenSigningError(
|
|
999
|
+
"Refusing to sign a delegation payload with no expected context. This typed data is a DELEGATION \u2014 signing it grants a third party authority to move funds \u2014 so it is only ever signed against a Haven-signed context that the caveats are verified against. Call this tool with { payment_id } instead (the signer then fetches and verifies the context itself), or use haven_sign_x402."
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
if (resolved?.kind !== "direct" && !edge.isPackedUserOperationTypedData(typedData)) {
|
|
1003
|
+
throw new HavenTypedDataNotAllowedError(
|
|
1004
|
+
"This signer signs only Haven-prepared payloads: a direct-payment PackedUserOperation from your own account that redeems a delegation granted to it, or a Haven-signed x402 context. Call haven_sign with payment_id (preferred), or pass x402_expected for an x402 funding leg."
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
try {
|
|
1008
|
+
edge.assertUserOpTypedDataBinding(typedData, payloadHash);
|
|
1009
|
+
} catch (err) {
|
|
1010
|
+
if (err instanceof edge.HavenUserOpBindingError) {
|
|
1011
|
+
throw new HavenUserOpBindingRefusedError(err.message);
|
|
1012
|
+
}
|
|
1013
|
+
throw err;
|
|
1014
|
+
}
|
|
1015
|
+
assertBoundDirectPaymentUserOp(typedData, signer.delegateAddress);
|
|
1016
|
+
const signature = await signer.signDelegationTypedData(typedData);
|
|
1017
|
+
await auditSigning("haven_sign", viem.hashTypedData(typedData));
|
|
1018
|
+
return { signature, ...clientUpdateField(resolved) };
|
|
1019
|
+
}
|
|
1020
|
+
throw new HavenBareHashRefusedError();
|
|
1021
|
+
}
|
|
1022
|
+
await auditSigning("haven_sign", viem.hashTypedData(typedData));
|
|
1023
|
+
return { signature: result.signature, x402_binding: result.x402Binding, ...clientUpdateField(resolved) };
|
|
1024
|
+
}),
|
|
1025
|
+
haven_x402_sign_header: async (input) => runTool(async () => {
|
|
1026
|
+
const args = parse("haven_x402_sign_header", coercePaymentRequired(input));
|
|
1027
|
+
const result = await signer.buildX402PaymentHeader(
|
|
1028
|
+
args.payment_required,
|
|
1029
|
+
args.x402_binding
|
|
1030
|
+
);
|
|
1031
|
+
await auditSigning(
|
|
1032
|
+
"haven_x402_sign_header",
|
|
1033
|
+
hashPayloadForAudit(args.payment_required)
|
|
1034
|
+
);
|
|
1035
|
+
return { payment_header: result.paymentHeader, accepted: result.accepted };
|
|
1036
|
+
}),
|
|
1037
|
+
haven_sign_x402: async (input) => runTool(async () => {
|
|
1038
|
+
const args = parse("haven_sign_x402", coerceX402Expected(coercePaymentRequired(input)));
|
|
1039
|
+
const resolved = await resolveSignContext(args, { allowDirectFallback: false });
|
|
1040
|
+
const fetched = resolved?.kind === "x402" ? resolved.ctx : void 0;
|
|
1041
|
+
const payloadHash = fetched?.payloadHash ?? args.payload_hash;
|
|
1042
|
+
const expectedRaw = fetched ? parseFetchedExpected(fetched) : args.x402_expected;
|
|
1043
|
+
if (!payloadHash || !expectedRaw) {
|
|
1044
|
+
throw new edge.HavenSigningError(
|
|
1045
|
+
"Pass payment_id (preferred \u2014 the signer fetches the signing context itself) or payload_hash + x402_expected from the quote result."
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
const fundingTypedData = fetched?.typedData ?? resolveTypedData(args);
|
|
1049
|
+
const funding = await signFundingLeg(signer, toExpectedX402(expectedRaw), fundingTypedData);
|
|
1050
|
+
const paymentRequired = fetched?.paymentRequired ?? args.payment_required;
|
|
1051
|
+
if (!paymentRequired) {
|
|
1052
|
+
throw new edge.HavenSigningError(
|
|
1053
|
+
fetched ? "The Haven sign-context for this payment_id carried no payment_required (backend predates #1355). Pass payment_required from the pay result explicitly, verbatim." : "payment_required is required on the quote-based path (no payment_id to fetch it by). Pass it verbatim from the quote result."
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
const header = await signer.buildX402PaymentHeader(
|
|
1057
|
+
paymentRequired,
|
|
1058
|
+
funding.x402Binding
|
|
1059
|
+
);
|
|
1060
|
+
await auditSigning(
|
|
1061
|
+
"haven_sign_x402",
|
|
1062
|
+
viem.hashTypedData(fundingTypedData)
|
|
1063
|
+
);
|
|
1064
|
+
await auditSigning("haven_sign_x402", hashPayloadForAudit(paymentRequired));
|
|
1065
|
+
return {
|
|
1066
|
+
signature: funding.signature,
|
|
1067
|
+
x402_binding: funding.x402Binding,
|
|
1068
|
+
payment_header: header.paymentHeader,
|
|
1069
|
+
accepted: header.accepted,
|
|
1070
|
+
...clientUpdateField(resolved)
|
|
1071
|
+
};
|
|
1072
|
+
}),
|
|
1073
|
+
haven_sign_sweep_delegate: async (input) => runTool(async () => {
|
|
1074
|
+
const args = parse("haven_sign_sweep_delegate", input);
|
|
1075
|
+
const result = await signer.signSweepAuthorization({
|
|
1076
|
+
authorization: args.authorization,
|
|
1077
|
+
expectedAuth: args.expected_auth,
|
|
1078
|
+
// Cross-check `to` against the agent's account (Haven wallet) from the
|
|
1079
|
+
// local credential, when the credential carries one.
|
|
1080
|
+
expectedSafe: options.audit?.accountAddress
|
|
1081
|
+
});
|
|
1082
|
+
await auditSigning(
|
|
1083
|
+
"haven_sign_sweep_delegate",
|
|
1084
|
+
hashPayloadForAudit(args.authorization)
|
|
1085
|
+
);
|
|
1086
|
+
return { signature: result.signature };
|
|
1087
|
+
})
|
|
1088
|
+
};
|
|
1089
|
+
async function auditSigning(tool, payloadHash) {
|
|
1090
|
+
if (!options.audit) return;
|
|
1091
|
+
const { auditPath, ...context } = options.audit;
|
|
1092
|
+
try {
|
|
1093
|
+
await appendSigningAuditEntry(
|
|
1094
|
+
createSigningAuditEntry(tool, payloadHash, {
|
|
1095
|
+
...context,
|
|
1096
|
+
delegateAddress: signer.delegateAddress
|
|
1097
|
+
}),
|
|
1098
|
+
auditPath
|
|
1099
|
+
);
|
|
1100
|
+
} catch (err) {
|
|
1101
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1102
|
+
process.stderr.write(`haven-signer: warning: audit entry for ${tool} could not be written to ${auditPath}: ${message}
|
|
1103
|
+
`);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
function parse(name, input) {
|
|
1108
|
+
return v3.z.object(toolSchemas[name]).parse(input ?? {});
|
|
1109
|
+
}
|
|
1110
|
+
function coerceX402Expected(input) {
|
|
1111
|
+
if (!input || typeof input !== "object") return input;
|
|
1112
|
+
const record = input;
|
|
1113
|
+
let value = record.x402_expected;
|
|
1114
|
+
if (typeof value === "string") {
|
|
1115
|
+
try {
|
|
1116
|
+
value = JSON.parse(value);
|
|
1117
|
+
} catch {
|
|
1118
|
+
return input;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
if (!value || typeof value !== "object") return input;
|
|
1122
|
+
const obj = value;
|
|
1123
|
+
if (obj.auth === void 0 && obj.expected && typeof obj.expected === "object") {
|
|
1124
|
+
return { ...record, x402_expected: obj.expected };
|
|
1125
|
+
}
|
|
1126
|
+
if (value !== record.x402_expected) return { ...record, x402_expected: value };
|
|
1127
|
+
return input;
|
|
1128
|
+
}
|
|
1129
|
+
function coercePaymentRequired(input) {
|
|
1130
|
+
if (!input || typeof input !== "object") return input;
|
|
1131
|
+
const record = input;
|
|
1132
|
+
if (typeof record.payment_required !== "string") return input;
|
|
1133
|
+
try {
|
|
1134
|
+
return { ...record, payment_required: JSON.parse(record.payment_required) };
|
|
1135
|
+
} catch {
|
|
1136
|
+
return input;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
async function runTool(fn) {
|
|
1140
|
+
try {
|
|
1141
|
+
return { success: true, data: await fn() };
|
|
1142
|
+
} catch (err) {
|
|
1143
|
+
return normalizeError(err);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
function normalizeError(err) {
|
|
1147
|
+
if (err instanceof v3.z.ZodError) {
|
|
1148
|
+
return {
|
|
1149
|
+
success: false,
|
|
1150
|
+
code: "INVALID_INPUT",
|
|
1151
|
+
message: err.errors.map((e) => `${e.path.join(".") || "(root)"}: ${e.message}`).join("; "),
|
|
1152
|
+
statusCode: 400
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
if (err instanceof edge.HavenUnsupportedSignerVersionError) {
|
|
1156
|
+
return {
|
|
1157
|
+
success: false,
|
|
1158
|
+
code: err.code,
|
|
1159
|
+
message: err.message,
|
|
1160
|
+
supported_versions: [...err.supportedVersions],
|
|
1161
|
+
received_version: err.receivedVersion,
|
|
1162
|
+
fallback: err.fallback,
|
|
1163
|
+
next_action: edge.AgentPaymentNextAction.StopAndTellUser,
|
|
1164
|
+
// #3103: no tool can fix a version skew from inside the call.
|
|
1165
|
+
...nextStepWireFields(signerRefusalStep({
|
|
1166
|
+
nextAction: edge.AgentPaymentNextAction.StopAndTellUser,
|
|
1167
|
+
nextTool: null,
|
|
1168
|
+
nextToolOmittedReason: "update @haven_ai/signer by re-running the connector, then repeat the same call"
|
|
1169
|
+
}))
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
if (err instanceof HavenBareHashRefusedError) {
|
|
1173
|
+
return {
|
|
1174
|
+
success: false,
|
|
1175
|
+
code: err.code,
|
|
1176
|
+
message: err.message,
|
|
1177
|
+
next_action: err.next_action,
|
|
1178
|
+
...nextStepWireFields(err.step)
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
if (err instanceof HavenUserOpBindingRefusedError) {
|
|
1182
|
+
return {
|
|
1183
|
+
success: false,
|
|
1184
|
+
code: err.code,
|
|
1185
|
+
message: err.message,
|
|
1186
|
+
next_action: err.next_action,
|
|
1187
|
+
...nextStepWireFields(err.step)
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1190
|
+
if (err instanceof HavenTypedDataNotAllowedError) {
|
|
1191
|
+
return {
|
|
1192
|
+
success: false,
|
|
1193
|
+
code: err.code,
|
|
1194
|
+
message: err.message,
|
|
1195
|
+
next_action: err.next_action,
|
|
1196
|
+
...nextStepWireFields(err.step)
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
if (err instanceof HavenSignContextError) {
|
|
1200
|
+
return {
|
|
1201
|
+
success: false,
|
|
1202
|
+
code: err.code,
|
|
1203
|
+
message: err.message,
|
|
1204
|
+
next_action: err.next_action,
|
|
1205
|
+
...err.fallback !== void 0 ? { fallback: err.fallback } : {},
|
|
1206
|
+
...err.retry_with_new_quote ? { retry_with_new_quote: true } : {},
|
|
1207
|
+
...err.http_status !== void 0 ? { http_status: err.http_status } : {},
|
|
1208
|
+
...err.backend_error_code !== void 0 ? { backend_error_code: err.backend_error_code } : {},
|
|
1209
|
+
...err.client_update ? { client_update: err.client_update } : {},
|
|
1210
|
+
// #3103: the typed step the error decided beside its action.
|
|
1211
|
+
...err.next_tool ? { next_tool: err.next_tool } : {},
|
|
1212
|
+
...err.next_tool_server ? { next_tool_server: err.next_tool_server } : {},
|
|
1213
|
+
...err.next_tool_name ? { next_tool_name: err.next_tool_name } : {},
|
|
1214
|
+
...err.next_tool_server_role ? { next_tool_server_role: err.next_tool_server_role } : {},
|
|
1215
|
+
...err.next_arguments ? { next_arguments: err.next_arguments } : {},
|
|
1216
|
+
...err.next_tool_omitted_reason ? { next_tool_omitted_reason: err.next_tool_omitted_reason } : {}
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
if (err instanceof edge.HavenSigningError) {
|
|
1220
|
+
return { success: false, code: err.code, message: err.message };
|
|
1221
|
+
}
|
|
1222
|
+
if (err instanceof edge.HavenApiError) {
|
|
1223
|
+
return { success: false, code: err.code, message: err.message, statusCode: err.statusCode };
|
|
1224
|
+
}
|
|
1225
|
+
if (err instanceof edge.HavenError) {
|
|
1226
|
+
return {
|
|
1227
|
+
success: false,
|
|
1228
|
+
code: err.code,
|
|
1229
|
+
message: err.message,
|
|
1230
|
+
statusCode: err.statusCode,
|
|
1231
|
+
paymentId: err.paymentId,
|
|
1232
|
+
...err.code === edge.AgentPaymentFailureCode.PaymentWindowExpired ? {
|
|
1233
|
+
next_action: edge.AgentPaymentNextAction.PaymentWindowExpired,
|
|
1234
|
+
retry_with_new_quote: true,
|
|
1235
|
+
suggested_tool: "haven_pay_mcp_tool",
|
|
1236
|
+
// #3103: same omission as the hosted window-expired helper.
|
|
1237
|
+
...nextStepWireFields(signerRefusalStep({
|
|
1238
|
+
nextAction: edge.AgentPaymentNextAction.PaymentWindowExpired,
|
|
1239
|
+
nextTool: null,
|
|
1240
|
+
nextToolOmittedReason: "re-run the hosted quote tool you called with the same idempotency_key; which one depends on the flow (suggested_tool names the MCP one)"
|
|
1241
|
+
}))
|
|
1242
|
+
} : {}
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
return {
|
|
1246
|
+
success: false,
|
|
1247
|
+
code: "UNKNOWN_ERROR",
|
|
1248
|
+
message: err instanceof Error ? err.message : String(err)
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// src/consent.ts
|
|
1253
|
+
var toolSummaries = {
|
|
1254
|
+
haven_sign: "signs a Haven-prepared payment, task-budget open, or task-budget close (a UserOp from your own account whose only call redeems a delegation made to it, directly or through a self-delegated task-budget child, or an x402 funding intent against a Haven-signed context)",
|
|
1255
|
+
haven_x402_sign_header: "builds and signs the EIP-3009 merchant payment header for an x402 payment",
|
|
1256
|
+
haven_sign_x402: "the two above in one call: funding payload + merchant header",
|
|
1257
|
+
haven_sign_sweep_delegate: "signs a Haven-prepared gasless sweep of stranded funds back to your own account"
|
|
1258
|
+
};
|
|
1259
|
+
var CONNECTOR_DOCTOR_HINT = [
|
|
1260
|
+
"Wired by the Haven connector (Claude Desktop, Codex, ...)? Diagnose and repair with: npx @haven_ai/connect --doctor",
|
|
1261
|
+
" (the doctor shows this as a failed 'Signer stdio handshake' check; the connector's setup outcome reports it as",
|
|
1262
|
+
" local_signer_ack_required; the repair is the connector's --ack-local-tools)."
|
|
1263
|
+
].join("\n");
|
|
1264
|
+
var SIGNER_ACK_ENV = "HAVEN_SIGNER_ACK";
|
|
1265
|
+
var SIGNER_CONSENT_SURFACE_VERSION = 2;
|
|
1266
|
+
function computeSignerConsentHash(input) {
|
|
1267
|
+
const identity = [
|
|
1268
|
+
input.delegateAddress.toLowerCase(),
|
|
1269
|
+
(input.accountAddress ?? "").toLowerCase(),
|
|
1270
|
+
input.agentId ?? "",
|
|
1271
|
+
input.chainId ?? "",
|
|
1272
|
+
input.network ?? ""
|
|
1273
|
+
].join("|");
|
|
1274
|
+
const toolCanonical = [...input.toolNames].sort().join(",");
|
|
1275
|
+
return crypto.createHash("sha256").update(`${identity}
|
|
1276
|
+
${toolCanonical}
|
|
1277
|
+
surface:v${SIGNER_CONSENT_SURFACE_VERSION}`).digest("hex").slice(0, 16);
|
|
1278
|
+
}
|
|
1279
|
+
function renderSignerConsentBlock(input, hash) {
|
|
1280
|
+
const lines = [
|
|
1281
|
+
"",
|
|
1282
|
+
"------------------------------------------------------------",
|
|
1283
|
+
"Haven edge signer - first-launch consent",
|
|
1284
|
+
"------------------------------------------------------------",
|
|
1285
|
+
"",
|
|
1286
|
+
`Delegate address: ${input.delegateAddress}`
|
|
1287
|
+
];
|
|
1288
|
+
lines.push(`Haven wallet: ${input.accountAddress ?? "not provided to this signer"}`);
|
|
1289
|
+
if (input.agentId) lines.push(`Agent ID: ${input.agentId}`);
|
|
1290
|
+
if (typeof input.chainId === "number") lines.push(`Chain ID: ${input.chainId}`);
|
|
1291
|
+
if (input.network) lines.push(`Network: ${input.network}`);
|
|
1292
|
+
lines.push("");
|
|
1293
|
+
lines.push("This local signer holds the delegate key on this machine and signs");
|
|
1294
|
+
lines.push("payment payloads or x402 merchant headers for the delegate address above.");
|
|
1295
|
+
lines.push("Its one network use is a read-only fetch of a pending payment's signing");
|
|
1296
|
+
lines.push("payload from Haven, authenticated with the agent credential stored next to");
|
|
1297
|
+
lines.push("this signer's key file - it never sends the key, a signature, or anything");
|
|
1298
|
+
lines.push("else outbound, and it cannot show a live allowance summary.");
|
|
1299
|
+
lines.push("The agent's signed budget delegation is the real spend gate, and the wallet");
|
|
1300
|
+
lines.push("owner can pause or revoke agent authority outside this signer.");
|
|
1301
|
+
lines.push("");
|
|
1302
|
+
lines.push("Tools this signer will expose to your agent runtime (one line each; the");
|
|
1303
|
+
lines.push("full agent-facing descriptions are what the runtime sees):");
|
|
1304
|
+
for (const name of input.toolNames) {
|
|
1305
|
+
lines.push(` - ${name}: ${toolSummaries[name]}`);
|
|
1306
|
+
}
|
|
1307
|
+
lines.push("");
|
|
1308
|
+
lines.push("A local audit entry is appended for every signing operation. Audit entries");
|
|
1309
|
+
lines.push("record timestamp, tool, payload hash, and delegate address; never the key,");
|
|
1310
|
+
lines.push("signature, or x402 payment header.");
|
|
1311
|
+
lines.push("");
|
|
1312
|
+
lines.push(`Consent hash: ${hash}`);
|
|
1313
|
+
lines.push("");
|
|
1314
|
+
lines.push("To acknowledge, EITHER:");
|
|
1315
|
+
lines.push(` - set ${SIGNER_ACK_ENV}=${hash} in this process's environment, OR`);
|
|
1316
|
+
lines.push(" - re-run with --ack to write the acknowledgement next to your");
|
|
1317
|
+
lines.push(" credential file (sidecar <credentials>.signer-ack.json).");
|
|
1318
|
+
lines.push("");
|
|
1319
|
+
lines.push(CONNECTOR_DOCTOR_HINT);
|
|
1320
|
+
lines.push("");
|
|
1321
|
+
lines.push("------------------------------------------------------------");
|
|
1322
|
+
lines.push("");
|
|
1323
|
+
return lines.join("\n");
|
|
1324
|
+
}
|
|
1325
|
+
async function ensureSignerConsent(input, options = {}) {
|
|
1326
|
+
const env = options.env ?? process.env;
|
|
1327
|
+
const out = options.out ?? process.stderr;
|
|
1328
|
+
const hash = computeSignerConsentHash(input);
|
|
1329
|
+
const envAck = env[SIGNER_ACK_ENV];
|
|
1330
|
+
if (typeof envAck === "string" && envAck.length > 0) {
|
|
1331
|
+
if (envAck === hash) return { ok: true, hash, reason: "env_var_match" };
|
|
1332
|
+
out.write(renderSignerConsentBlock(input, hash));
|
|
1333
|
+
out.write(
|
|
1334
|
+
`${SIGNER_ACK_ENV} was set but did not match the current signer consent hash.
|
|
1335
|
+
Expected: ${hash}
|
|
1336
|
+
Got: ${envAck}
|
|
1337
|
+
Re-acknowledge with the new hash above, or run with --ack.
|
|
1338
|
+
|
|
1339
|
+
`
|
|
1340
|
+
);
|
|
1341
|
+
return { ok: false, hash, reason: "env_var_mismatch" };
|
|
1342
|
+
}
|
|
1343
|
+
const ackPath = sidecarPath(options.credentialsPath);
|
|
1344
|
+
if (ackPath) {
|
|
1345
|
+
const stored = await readAckFile(ackPath);
|
|
1346
|
+
if (stored?.ack === hash) return { ok: true, hash, reason: "ack_file_match" };
|
|
1347
|
+
}
|
|
1348
|
+
if (options.writeAck && ackPath) {
|
|
1349
|
+
out.write(renderSignerConsentBlock(input, hash));
|
|
1350
|
+
await writeAckFile(ackPath, hash);
|
|
1351
|
+
out.write(`Wrote acknowledgement to ${ackPath}
|
|
1352
|
+
|
|
1353
|
+
`);
|
|
1354
|
+
return { ok: true, hash, reason: "wrote_ack_file" };
|
|
1355
|
+
}
|
|
1356
|
+
out.write(renderSignerConsentBlock(input, hash));
|
|
1357
|
+
return { ok: false, hash, reason: "no_acknowledgement" };
|
|
1358
|
+
}
|
|
1359
|
+
function registeredSignerToolNames() {
|
|
1360
|
+
return Object.keys(toolSchemas);
|
|
1361
|
+
}
|
|
1362
|
+
function sidecarPath(credentialsPath) {
|
|
1363
|
+
if (!credentialsPath) return null;
|
|
1364
|
+
return path.resolve(`${credentialsPath}.signer-ack.json`);
|
|
1365
|
+
}
|
|
1366
|
+
async function readAckFile(path) {
|
|
1367
|
+
try {
|
|
1368
|
+
const raw = await promises.readFile(path, "utf8");
|
|
1369
|
+
const parsed = JSON.parse(raw);
|
|
1370
|
+
return { ack: typeof parsed.ack === "string" ? parsed.ack : void 0 };
|
|
1371
|
+
} catch {
|
|
1372
|
+
return null;
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
async function writeAckFile(path$1, hash) {
|
|
1376
|
+
await promises.mkdir(path.dirname(path$1), { recursive: true });
|
|
1377
|
+
await promises.writeFile(
|
|
1378
|
+
path$1,
|
|
1379
|
+
JSON.stringify({ ack: hash, at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2),
|
|
1380
|
+
"utf8"
|
|
1381
|
+
);
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// src/cli-args.ts
|
|
1385
|
+
var CONNECTOR_DOCTOR_COMMAND = "npx @haven_ai/connect --doctor";
|
|
1386
|
+
function parseSignerArgs(argv) {
|
|
1387
|
+
const options = {};
|
|
1388
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
1389
|
+
const arg = argv[i];
|
|
1390
|
+
if (arg === "--credentials" || arg === "--credentials-path") {
|
|
1391
|
+
options.credentialsPath = argv[i + 1];
|
|
1392
|
+
i += 1;
|
|
1393
|
+
} else if (arg === "--ack") {
|
|
1394
|
+
options.writeAck = true;
|
|
1395
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
1396
|
+
return { kind: "help", text: helpText() };
|
|
1397
|
+
} else {
|
|
1398
|
+
return {
|
|
1399
|
+
kind: "unknown-option",
|
|
1400
|
+
option: arg,
|
|
1401
|
+
text: `haven-signer: unknown option ${arg}. Run with --help for the options this signer takes` + (arg === "--ack-local-tools" ? ` \u2014 --ack-local-tools belongs to the connector (${CONNECTOR_DOCTOR_COMMAND}), not the signer.` : ".")
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
return { kind: "run", options };
|
|
1406
|
+
}
|
|
1407
|
+
function helpText() {
|
|
1408
|
+
const tools = registeredSignerToolNames();
|
|
1409
|
+
return [
|
|
1410
|
+
"Haven edge signer (local, holds the delegate key)",
|
|
1411
|
+
"",
|
|
1412
|
+
"Runs a local stdio MCP server exposing sign-only tools:",
|
|
1413
|
+
...tools.map((name) => ` - ${name}`),
|
|
1414
|
+
"Pair it with the hosted, keyless Haven MCP server: the hosted server",
|
|
1415
|
+
"constructs and relays, this one signs.",
|
|
1416
|
+
"",
|
|
1417
|
+
"Usage:",
|
|
1418
|
+
" npx @haven_ai/signer --credentials /path/to/agent.json",
|
|
1419
|
+
"",
|
|
1420
|
+
"Options:",
|
|
1421
|
+
" --credentials <path> Haven credential JSON (delegate_key is read from it).",
|
|
1422
|
+
" Alias: --credentials-path. Also supported: HAVEN_CREDENTIALS,",
|
|
1423
|
+
" or HAVEN_DELEGATE_KEY.",
|
|
1424
|
+
" --ack Acknowledge the first-launch consent block and write",
|
|
1425
|
+
" a signer sidecar acknowledgement next to the credential.",
|
|
1426
|
+
" --help, -h This text. Any other option is refused (exit 2).",
|
|
1427
|
+
"",
|
|
1428
|
+
"Consent:",
|
|
1429
|
+
" On first launch the signer prints the sign-only tool list and delegate",
|
|
1430
|
+
" address, then refuses to start unless acknowledged. Its only Haven API call",
|
|
1431
|
+
" is a read-only fetch of the signing payload for one pending payment, so it",
|
|
1432
|
+
" cannot show a live allowance summary.",
|
|
1433
|
+
" Acknowledge with EITHER --ack OR HAVEN_SIGNER_ACK=<hash> in your environment.",
|
|
1434
|
+
"",
|
|
1435
|
+
"Connector-wired (the usual case)?",
|
|
1436
|
+
` Diagnose and repair with: ${CONNECTOR_DOCTOR_COMMAND}`,
|
|
1437
|
+
" (an unacknowledged signer shows there as a failed 'Signer stdio handshake'",
|
|
1438
|
+
" check, and in the connector's setup outcome as local_signer_ack_required;",
|
|
1439
|
+
" the repair is the connector's --ack-local-tools, not a signer flag).",
|
|
1440
|
+
""
|
|
1441
|
+
].join("\n");
|
|
1442
|
+
}
|
|
1443
|
+
function createEdgeSigner(delegateKey, options = {}) {
|
|
1444
|
+
let delegateAddress;
|
|
1445
|
+
try {
|
|
1446
|
+
delegateAddress = edge.addressFromKey(delegateKey);
|
|
1447
|
+
} catch (err) {
|
|
1448
|
+
throw new edge.HavenSigningError(
|
|
1449
|
+
`Invalid delegate key: ${err instanceof Error ? err.message : String(err)}`
|
|
1450
|
+
);
|
|
1451
|
+
}
|
|
1452
|
+
const x402Bindings = /* @__PURE__ */ new Map();
|
|
1453
|
+
const retiredX402Bindings = /* @__PURE__ */ new Map();
|
|
1454
|
+
const RETIRED_BINDING_MEMORY = 64;
|
|
1455
|
+
function retireX402Binding(id, reason) {
|
|
1456
|
+
retiredX402Bindings.set(id, reason);
|
|
1457
|
+
if (retiredX402Bindings.size > RETIRED_BINDING_MEMORY) {
|
|
1458
|
+
const oldest = retiredX402Bindings.keys().next();
|
|
1459
|
+
if (!oldest.done) retiredX402Bindings.delete(oldest.value);
|
|
979
1460
|
}
|
|
980
|
-
return ctx;
|
|
981
1461
|
}
|
|
982
1462
|
return {
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
const
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
1463
|
+
delegateAddress,
|
|
1464
|
+
async signDelegationTypedData(typedData) {
|
|
1465
|
+
const account = accounts.privateKeyToAccount(delegateKey);
|
|
1466
|
+
return account.signTypedData(typedData);
|
|
1467
|
+
},
|
|
1468
|
+
async signX402FundingTypedData(typedData, expected) {
|
|
1469
|
+
if (expected?.auth) {
|
|
1470
|
+
assertSupportedBindingVersion(
|
|
1471
|
+
expected.auth.version,
|
|
1472
|
+
SUPPORTED_X402_EXPECTED_VERSIONS,
|
|
1473
|
+
"x402 expected context"
|
|
991
1474
|
);
|
|
992
1475
|
}
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1476
|
+
if (!typedData) {
|
|
1477
|
+
throw new edge.HavenSigningError(
|
|
1478
|
+
"This x402 funding intent is on the delegation rail: it commits to EIP-712 typed data, which was not supplied. Pass typed_data_b64 from haven_pay_mcp_tool / haven_pay_x402_quote through unchanged (or typed_data verbatim) \u2014 a bare payload_hash is not what the account validates."
|
|
1479
|
+
);
|
|
1480
|
+
}
|
|
1481
|
+
const digest = viem.hashTypedData(typedData);
|
|
1482
|
+
assertExpectedBinding(digest, expected, options.x402BindingSigner);
|
|
1483
|
+
assertPayerMatchesDelegate(expected, delegateAddress, options.agentId);
|
|
1484
|
+
if (edge.isSettlementChildTypedData(typedData)) {
|
|
1485
|
+
const settlementChainId = edge.chainIdForNetwork(expected.network);
|
|
1486
|
+
if (settlementChainId === void 0) {
|
|
1487
|
+
throw new edge.HavenSigningError(
|
|
1488
|
+
`Refusing to sign the x402 settlement child: this signer cannot map the network '${expected.network}' to a chain id, so it cannot check which chain the child is scoped to. Update @haven_ai/signer.`
|
|
1489
|
+
);
|
|
1006
1490
|
}
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1491
|
+
edge.assertOwnSettlementChild(typedData, {
|
|
1492
|
+
merchantTo: expected.merchantTo,
|
|
1493
|
+
amount: expected.amount,
|
|
1494
|
+
asset: expected.asset,
|
|
1495
|
+
// From the SIGNED network, not the payload's own claim. Passing
|
|
1496
|
+
// Number(typedData.domain.chainId) here made the check compare a
|
|
1497
|
+
// value to itself — vacuous, and precisely the "declared one thing,
|
|
1498
|
+
// signed another" class this file exists to catch (#1455 review).
|
|
1499
|
+
chainId: settlementChainId,
|
|
1500
|
+
expiresAt: expected.expiresAt
|
|
1501
|
+
}, delegateAddress);
|
|
1502
|
+
} else if (edge.isPackedUserOperationTypedData(typedData)) {
|
|
1503
|
+
edge.assertUserOpTypedDataBinding(typedData, expected.payloadHash);
|
|
1504
|
+
edge.assertBoundDirectPaymentUserOp(typedData, delegateAddress);
|
|
1505
|
+
edge.assertFundingLegPaysDelegate(typedData, {
|
|
1506
|
+
delegateAddress,
|
|
1507
|
+
asset: expected.asset,
|
|
1508
|
+
amount: expected.amount
|
|
1509
|
+
});
|
|
1510
|
+
} else {
|
|
1511
|
+
throw new edge.HavenTypedDataRefusedError(
|
|
1512
|
+
`This signer's x402 arm signs only a funding-leg PackedUserOperation or an erc7710 settlement Delegation; this typed data is '${String(
|
|
1513
|
+
typedData.primaryType
|
|
1514
|
+
)}'. Refusing, even though its digest is bound by the expected context.`
|
|
1515
|
+
);
|
|
1010
1516
|
}
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
haven_x402_sign_header: async (input) => runTool(async () => {
|
|
1015
|
-
const args = parse("haven_x402_sign_header", coercePaymentRequired(input));
|
|
1016
|
-
const result = await signer.buildX402PaymentHeader(
|
|
1017
|
-
args.payment_required,
|
|
1018
|
-
args.x402_binding
|
|
1019
|
-
);
|
|
1020
|
-
await auditSigning(
|
|
1021
|
-
"haven_x402_sign_header",
|
|
1022
|
-
hashPayloadForAudit(args.payment_required)
|
|
1517
|
+
const account = accounts.privateKeyToAccount(delegateKey);
|
|
1518
|
+
const signature = await account.signTypedData(
|
|
1519
|
+
typedData
|
|
1023
1520
|
);
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
const
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1521
|
+
const x402Binding = crypto.randomUUID();
|
|
1522
|
+
x402Bindings.set(x402Binding, { ...expected });
|
|
1523
|
+
return { signature, x402Binding };
|
|
1524
|
+
},
|
|
1525
|
+
async buildX402PaymentHeader(paymentRequired, x402Binding) {
|
|
1526
|
+
const expected = x402Bindings.get(x402Binding);
|
|
1527
|
+
if (!expected) {
|
|
1528
|
+
const retired = retiredX402Bindings.get(x402Binding);
|
|
1529
|
+
if (retired === "header_built") {
|
|
1530
|
+
throw new edge.HavenSigningError(
|
|
1531
|
+
"This x402 binding was already used to build a merchant header. Bindings are single-use. If you called haven_sign_x402, it already returned the payment_header \u2014 retry the merchant with THAT header instead of building another; haven_x402_sign_header is the follow-up to haven_sign, not to haven_sign_x402. If you no longer have the header, re-run the quote tool with the same idempotency_key."
|
|
1532
|
+
);
|
|
1533
|
+
}
|
|
1534
|
+
if (retired === "window_expired") {
|
|
1535
|
+
throw new edge.HavenSigningError(
|
|
1536
|
+
"This x402 binding was retired because its payment window closed before a merchant header could be built \u2014 no header exists to retry with. Re-run the quote tool with the same idempotency_key to get a fresh window, then sign again."
|
|
1537
|
+
);
|
|
1538
|
+
}
|
|
1539
|
+
throw new edge.HavenSigningError(
|
|
1540
|
+
"x402 funding binding is required before signing a merchant header. Sign the hosted funding payload with x402_expected first (haven_sign returns a binding this tool can use). A binding is also lost when the signer process restarts, since bindings live in memory only \u2014 re-sign to mint a fresh one."
|
|
1034
1541
|
);
|
|
1035
1542
|
}
|
|
1036
|
-
const
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1543
|
+
const assertWindowOpenOrRetire = () => {
|
|
1544
|
+
try {
|
|
1545
|
+
assertX402PaymentWindowOpen(expected);
|
|
1546
|
+
} catch (err) {
|
|
1547
|
+
x402Bindings.delete(x402Binding);
|
|
1548
|
+
retireX402Binding(x402Binding, "window_expired");
|
|
1549
|
+
throw err;
|
|
1550
|
+
}
|
|
1551
|
+
};
|
|
1552
|
+
assertWindowOpenOrRetire();
|
|
1553
|
+
const option = edge.selectStandardPaymentOption(paymentRequired.accepts);
|
|
1554
|
+
if (!option) {
|
|
1555
|
+
throw new edge.HavenApiError(
|
|
1556
|
+
"No compatible payment option found in x402 requirements. Haven supports standard x402 exact payments on Base USDC.",
|
|
1557
|
+
400
|
|
1046
1558
|
);
|
|
1047
1559
|
}
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1560
|
+
assertX402MatchesExpected(paymentRequired, option, expected);
|
|
1561
|
+
const account = accounts.privateKeyToAccount(delegateKey);
|
|
1562
|
+
const requirements = edge.toStandardPaymentRequirements(paymentRequired, option);
|
|
1563
|
+
const { exact } = await loadX402Schemes();
|
|
1564
|
+
assertWindowOpenOrRetire();
|
|
1565
|
+
const header = await exact.evm.createPaymentHeader(
|
|
1566
|
+
account,
|
|
1567
|
+
paymentRequired.x402Version,
|
|
1568
|
+
requirements
|
|
1051
1569
|
);
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1570
|
+
if (paymentRequired.x402Version < 2) {
|
|
1571
|
+
x402Bindings.delete(x402Binding);
|
|
1572
|
+
retireX402Binding(x402Binding, "header_built");
|
|
1573
|
+
return { paymentHeader: header, accepted: option };
|
|
1574
|
+
}
|
|
1575
|
+
try {
|
|
1576
|
+
const payment = edge.decodeBase64Json(header);
|
|
1577
|
+
const wrapped = edge.encodeBase64Json(
|
|
1578
|
+
edge.x402V2PaymentEnvelope(paymentRequired, option, payment.payload)
|
|
1579
|
+
);
|
|
1580
|
+
return { paymentHeader: wrapped, accepted: option };
|
|
1581
|
+
} finally {
|
|
1582
|
+
x402Bindings.delete(x402Binding);
|
|
1583
|
+
retireX402Binding(x402Binding, "header_built");
|
|
1584
|
+
}
|
|
1585
|
+
},
|
|
1586
|
+
async signSweepAuthorization({
|
|
1587
|
+
authorization,
|
|
1588
|
+
expectedAuth,
|
|
1589
|
+
expectedSafe
|
|
1590
|
+
}) {
|
|
1591
|
+
assertSweepBinding(authorization, expectedAuth, options.x402BindingSigner);
|
|
1592
|
+
if (!sameAddress(authorization.from, delegateAddress)) {
|
|
1593
|
+
throw new edge.HavenSigningError(
|
|
1594
|
+
"Sweep authorization `from` does not match this delegate address."
|
|
1595
|
+
);
|
|
1596
|
+
}
|
|
1597
|
+
if (expectedSafe && !sameAddress(authorization.to, expectedSafe)) {
|
|
1598
|
+
throw new edge.HavenSigningError(
|
|
1599
|
+
"Sweep authorization `to` does not match the agent's account (Haven wallet) in the local credential."
|
|
1600
|
+
);
|
|
1601
|
+
}
|
|
1602
|
+
const typedData = edge.buildSweepTypedData(authorization);
|
|
1603
|
+
const viemTypedData = {
|
|
1604
|
+
domain: {
|
|
1605
|
+
...typedData.domain,
|
|
1606
|
+
verifyingContract: typedData.domain.verifyingContract
|
|
1607
|
+
},
|
|
1608
|
+
types: typedData.types,
|
|
1609
|
+
primaryType: typedData.primaryType,
|
|
1610
|
+
message: {
|
|
1611
|
+
...typedData.message,
|
|
1612
|
+
from: typedData.message.from,
|
|
1613
|
+
to: typedData.message.to,
|
|
1614
|
+
nonce: typedData.message.nonce
|
|
1615
|
+
}
|
|
1059
1616
|
};
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
const
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
"haven_sign_sweep_delegate",
|
|
1071
|
-
hashPayloadForAudit(args.authorization)
|
|
1072
|
-
);
|
|
1073
|
-
return { signature: result.signature };
|
|
1074
|
-
})
|
|
1617
|
+
const account = accounts.privateKeyToAccount(delegateKey);
|
|
1618
|
+
const signature = await account.signTypedData(viemTypedData);
|
|
1619
|
+
const recovered = await viem.recoverTypedDataAddress({ ...viemTypedData, signature });
|
|
1620
|
+
if (!sameAddress(recovered, delegateAddress)) {
|
|
1621
|
+
throw new edge.HavenSigningError(
|
|
1622
|
+
"Local sweep signature verification failed \u2014 recovered address does not match the delegate key."
|
|
1623
|
+
);
|
|
1624
|
+
}
|
|
1625
|
+
return { signature };
|
|
1626
|
+
}
|
|
1075
1627
|
};
|
|
1076
|
-
async function auditSigning(tool, payloadHash) {
|
|
1077
|
-
if (!options.audit) return;
|
|
1078
|
-
const { auditPath, ...context } = options.audit;
|
|
1079
|
-
await appendSigningAuditEntry(
|
|
1080
|
-
createSigningAuditEntry(tool, payloadHash, {
|
|
1081
|
-
...context,
|
|
1082
|
-
delegateAddress: signer.delegateAddress
|
|
1083
|
-
}),
|
|
1084
|
-
auditPath
|
|
1085
|
-
);
|
|
1086
|
-
}
|
|
1087
|
-
}
|
|
1088
|
-
function parse(name, input) {
|
|
1089
|
-
return v3.z.object(toolSchemas[name]).parse(input ?? {});
|
|
1090
1628
|
}
|
|
1091
|
-
function
|
|
1092
|
-
if (!
|
|
1093
|
-
|
|
1094
|
-
let value = record.x402_expected;
|
|
1095
|
-
if (typeof value === "string") {
|
|
1096
|
-
try {
|
|
1097
|
-
value = JSON.parse(value);
|
|
1098
|
-
} catch {
|
|
1099
|
-
return input;
|
|
1100
|
-
}
|
|
1101
|
-
}
|
|
1102
|
-
if (!value || typeof value !== "object") return input;
|
|
1103
|
-
const obj = value;
|
|
1104
|
-
if (obj.auth === void 0 && obj.expected && typeof obj.expected === "object") {
|
|
1105
|
-
return { ...record, x402_expected: obj.expected };
|
|
1629
|
+
function assertSweepBinding(authorization, expectedAuth, trustedSigner) {
|
|
1630
|
+
if (!expectedAuth || typeof expectedAuth !== "object") {
|
|
1631
|
+
throw new edge.HavenSigningError("Sweep authorization binding is required before signing.");
|
|
1106
1632
|
}
|
|
1107
|
-
if (
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
if (!input || typeof input !== "object") return input;
|
|
1112
|
-
const record = input;
|
|
1113
|
-
if (typeof record.payment_required !== "string") return input;
|
|
1114
|
-
try {
|
|
1115
|
-
return { ...record, payment_required: JSON.parse(record.payment_required) };
|
|
1116
|
-
} catch {
|
|
1117
|
-
return input;
|
|
1633
|
+
if (!trustedSigner) {
|
|
1634
|
+
throw new edge.HavenSigningError(
|
|
1635
|
+
"Sweep binding verifier is not configured. Set HAVEN_X402_BINDING_SIGNER before signing sweep authorizations."
|
|
1636
|
+
);
|
|
1118
1637
|
}
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1638
|
+
assertSupportedBindingVersion(
|
|
1639
|
+
expectedAuth.version,
|
|
1640
|
+
SUPPORTED_SWEEP_BINDING_VERSIONS,
|
|
1641
|
+
"sweep authorization binding"
|
|
1642
|
+
);
|
|
1643
|
+
const message = edge.buildSweepAuthorizationMessage(authorization);
|
|
1644
|
+
if (expectedAuth.message !== message) {
|
|
1645
|
+
throw new edge.HavenSigningError("Sweep authorization binding does not match the authorization being signed.");
|
|
1125
1646
|
}
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
if (err instanceof v3.z.ZodError) {
|
|
1129
|
-
return {
|
|
1130
|
-
success: false,
|
|
1131
|
-
code: "INVALID_INPUT",
|
|
1132
|
-
message: err.errors.map((e) => `${e.path.join(".") || "(root)"}: ${e.message}`).join("; "),
|
|
1133
|
-
statusCode: 400
|
|
1134
|
-
};
|
|
1647
|
+
if (!sameAddress(expectedAuth.signer, trustedSigner)) {
|
|
1648
|
+
throw new edge.HavenSigningError("Sweep authorization binding was not signed by the configured Haven signer.");
|
|
1135
1649
|
}
|
|
1136
|
-
if (
|
|
1137
|
-
|
|
1138
|
-
success: false,
|
|
1139
|
-
code: err.code,
|
|
1140
|
-
message: err.message,
|
|
1141
|
-
supported_versions: [...err.supportedVersions],
|
|
1142
|
-
received_version: err.receivedVersion,
|
|
1143
|
-
fallback: err.fallback,
|
|
1144
|
-
next_action: sdk.AgentPaymentNextAction.StopAndTellUser,
|
|
1145
|
-
// #3103: no tool can fix a version skew from inside the call.
|
|
1146
|
-
...nextStepWireFields(signerRefusalStep({
|
|
1147
|
-
nextAction: sdk.AgentPaymentNextAction.StopAndTellUser,
|
|
1148
|
-
nextTool: null,
|
|
1149
|
-
nextToolOmittedReason: "update @haven_ai/signer by re-running the connector, then repeat the same call"
|
|
1150
|
-
}))
|
|
1151
|
-
};
|
|
1650
|
+
if (!edge.verifySignature(viem.hashMessage(message), expectedAuth.signature, trustedSigner)) {
|
|
1651
|
+
throw new edge.HavenSigningError("Sweep authorization binding signature could not be verified.");
|
|
1152
1652
|
}
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
...err.fallback !== void 0 ? { fallback: err.fallback } : {},
|
|
1160
|
-
...err.retry_with_new_quote ? { retry_with_new_quote: true } : {},
|
|
1161
|
-
...err.http_status !== void 0 ? { http_status: err.http_status } : {},
|
|
1162
|
-
...err.backend_error_code !== void 0 ? { backend_error_code: err.backend_error_code } : {},
|
|
1163
|
-
// #3103: the typed step the error decided beside its action.
|
|
1164
|
-
...err.next_tool ? { next_tool: err.next_tool } : {},
|
|
1165
|
-
...err.next_tool_server ? { next_tool_server: err.next_tool_server } : {},
|
|
1166
|
-
...err.next_tool_name ? { next_tool_name: err.next_tool_name } : {},
|
|
1167
|
-
...err.next_tool_server_role ? { next_tool_server_role: err.next_tool_server_role } : {},
|
|
1168
|
-
...err.next_arguments ? { next_arguments: err.next_arguments } : {},
|
|
1169
|
-
...err.next_tool_omitted_reason ? { next_tool_omitted_reason: err.next_tool_omitted_reason } : {}
|
|
1170
|
-
};
|
|
1653
|
+
}
|
|
1654
|
+
function assertX402MatchesExpected(paymentRequired, option, expected) {
|
|
1655
|
+
assertExpectedShape(expected);
|
|
1656
|
+
const headerResource = option.resource ?? paymentRequired.resource.url;
|
|
1657
|
+
if (headerResource !== expected.resourceUrl) {
|
|
1658
|
+
throw new edge.HavenSigningError("x402 payment_required resource does not match the funded intent.");
|
|
1171
1659
|
}
|
|
1172
|
-
if (
|
|
1173
|
-
|
|
1660
|
+
if (!sameAddress(option.payTo, expected.merchantTo)) {
|
|
1661
|
+
throw new edge.HavenSigningError("x402 merchant recipient does not match the funded intent.");
|
|
1174
1662
|
}
|
|
1175
|
-
if (
|
|
1176
|
-
|
|
1663
|
+
if (edge.x402AuthorizationAmount(option) !== expected.amount) {
|
|
1664
|
+
throw new edge.HavenSigningError("x402 amount does not match the funded intent.");
|
|
1177
1665
|
}
|
|
1178
|
-
if (
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
statusCode: err.statusCode,
|
|
1184
|
-
paymentId: err.paymentId,
|
|
1185
|
-
...err.code === sdk.AgentPaymentFailureCode.PaymentWindowExpired ? {
|
|
1186
|
-
next_action: sdk.AgentPaymentNextAction.PaymentWindowExpired,
|
|
1187
|
-
retry_with_new_quote: true,
|
|
1188
|
-
suggested_tool: "haven_pay_mcp_tool",
|
|
1189
|
-
// #3103: same omission as the hosted window-expired helper.
|
|
1190
|
-
...nextStepWireFields(signerRefusalStep({
|
|
1191
|
-
nextAction: sdk.AgentPaymentNextAction.PaymentWindowExpired,
|
|
1192
|
-
nextTool: null,
|
|
1193
|
-
nextToolOmittedReason: "re-run the hosted quote tool you called with the same idempotency_key; which one depends on the flow (suggested_tool names the MCP one)"
|
|
1194
|
-
}))
|
|
1195
|
-
} : {}
|
|
1196
|
-
};
|
|
1666
|
+
if (!sameAddress(option.asset, expected.asset)) {
|
|
1667
|
+
throw new edge.HavenSigningError("x402 asset does not match the funded intent.");
|
|
1668
|
+
}
|
|
1669
|
+
if (option.network !== expected.network) {
|
|
1670
|
+
throw new edge.HavenSigningError("x402 network does not match the funded intent.");
|
|
1197
1671
|
}
|
|
1198
|
-
return {
|
|
1199
|
-
success: false,
|
|
1200
|
-
code: "UNKNOWN_ERROR",
|
|
1201
|
-
message: err instanceof Error ? err.message : String(err)
|
|
1202
|
-
};
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
// src/consent.ts
|
|
1206
|
-
var SIGNER_ACK_ENV = "HAVEN_SIGNER_ACK";
|
|
1207
|
-
var SIGNER_CONSENT_SURFACE_VERSION = 2;
|
|
1208
|
-
function computeSignerConsentHash(input) {
|
|
1209
|
-
const identity = [
|
|
1210
|
-
input.delegateAddress.toLowerCase(),
|
|
1211
|
-
(input.accountAddress ?? "").toLowerCase(),
|
|
1212
|
-
input.agentId ?? "",
|
|
1213
|
-
input.chainId ?? "",
|
|
1214
|
-
input.network ?? ""
|
|
1215
|
-
].join("|");
|
|
1216
|
-
const toolCanonical = [...input.toolNames].sort().join(",");
|
|
1217
|
-
return crypto.createHash("sha256").update(`${identity}
|
|
1218
|
-
${toolCanonical}
|
|
1219
|
-
surface:v${SIGNER_CONSENT_SURFACE_VERSION}`).digest("hex").slice(0, 16);
|
|
1220
1672
|
}
|
|
1221
|
-
function
|
|
1222
|
-
|
|
1223
|
-
""
|
|
1224
|
-
"------------------------------------------------------------",
|
|
1225
|
-
"Haven edge signer - first-launch consent",
|
|
1226
|
-
"------------------------------------------------------------",
|
|
1227
|
-
"",
|
|
1228
|
-
`Delegate address: ${input.delegateAddress}`
|
|
1229
|
-
];
|
|
1230
|
-
lines.push(`Haven wallet: ${input.accountAddress ?? "not provided to this signer"}`);
|
|
1231
|
-
if (input.agentId) lines.push(`Agent ID: ${input.agentId}`);
|
|
1232
|
-
if (typeof input.chainId === "number") lines.push(`Chain ID: ${input.chainId}`);
|
|
1233
|
-
if (input.network) lines.push(`Network: ${input.network}`);
|
|
1234
|
-
lines.push("");
|
|
1235
|
-
lines.push("This local signer holds the delegate key on this machine and signs");
|
|
1236
|
-
lines.push("payment payloads or x402 merchant headers for the delegate address above.");
|
|
1237
|
-
lines.push("Its one network use is a read-only fetch of a pending payment's signing");
|
|
1238
|
-
lines.push("payload from Haven, authenticated with the agent credential stored next to");
|
|
1239
|
-
lines.push("this signer's key file - it never sends the key, a signature, or anything");
|
|
1240
|
-
lines.push("else outbound, and it cannot show a live allowance summary.");
|
|
1241
|
-
lines.push("On-chain Safe rules remain the real spend gate, and the wallet owner can");
|
|
1242
|
-
lines.push("pause or revoke agent authority outside this signer.");
|
|
1243
|
-
lines.push("");
|
|
1244
|
-
lines.push("Tools this signer will expose to your agent runtime:");
|
|
1245
|
-
for (const name of input.toolNames) {
|
|
1246
|
-
lines.push(` - ${name}`);
|
|
1247
|
-
lines.push(` ${toolDescriptions[name]}`);
|
|
1673
|
+
function assertExpectedShape(expected) {
|
|
1674
|
+
if (!expected || typeof expected !== "object") {
|
|
1675
|
+
throw new edge.HavenSigningError("x402 expected funding context is required before signing a merchant header.");
|
|
1248
1676
|
}
|
|
1249
|
-
lines.push("");
|
|
1250
|
-
lines.push("A local audit entry is appended for every signing operation. Audit entries");
|
|
1251
|
-
lines.push("record timestamp, tool, payload hash, and delegate address; never the key,");
|
|
1252
|
-
lines.push("signature, or x402 payment header.");
|
|
1253
|
-
lines.push("");
|
|
1254
|
-
lines.push(`Consent hash: ${hash}`);
|
|
1255
|
-
lines.push("");
|
|
1256
|
-
lines.push("To acknowledge, EITHER:");
|
|
1257
|
-
lines.push(` - set ${SIGNER_ACK_ENV}=${hash} in this process's environment, OR`);
|
|
1258
|
-
lines.push(" - re-run with --ack to write the acknowledgement next to your");
|
|
1259
|
-
lines.push(" credential file (sidecar <credentials>.signer-ack.json).");
|
|
1260
|
-
lines.push("");
|
|
1261
|
-
lines.push("------------------------------------------------------------");
|
|
1262
|
-
lines.push("");
|
|
1263
|
-
return lines.join("\n");
|
|
1264
1677
|
}
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
const
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1678
|
+
var SUPPORTED_X402_EXPECTED_VERSIONS = [2, 3];
|
|
1679
|
+
var SUPPORTED_SWEEP_BINDING_VERSIONS = [1];
|
|
1680
|
+
function assertSupportedBindingVersion(received, supported, context) {
|
|
1681
|
+
if (supported.includes(received)) return;
|
|
1682
|
+
const highest = Math.max(...supported);
|
|
1683
|
+
const outOfDate = received > highest;
|
|
1684
|
+
const code = context === "x402 expected context" ? edge.SignerRefusalCode.UnsupportedExpectedContextVersion : edge.SignerRefusalCode.UnsupportedSweepBindingVersion;
|
|
1685
|
+
const ceiling = outOfDate ? `This signer is out of date: it supports ${context} versions up to ${highest}, and Haven sent version ${received}. Update @haven_ai/signer \u2014 rerun the Haven connector (\`${edge.connectorRerunCommand()}\`), which reinstalls the pinned MCP runtime.` : `Unsupported ${context} version ${received}: this signer supports ${supported.join(", ")}.`;
|
|
1686
|
+
const fallback = outOfDate ? edge.SIGNER_UPDATE_FALLBACK : `This ${context} version (${received}) is older than what this signer enforces (${supported.join(", ")}) \u2014 updating @haven_ai/signer will not restore it. Nothing was signed or spent; stop and tell the user rather than retrying.`;
|
|
1687
|
+
throw new edge.HavenUnsupportedSignerVersionError(
|
|
1688
|
+
`${ceiling} Nothing was signed. Do not rewrite the version field to a supported value: it is part of the Haven-signed binding message, so changing it invalidates the signature and would misrepresent what Haven declared.`,
|
|
1689
|
+
code,
|
|
1690
|
+
supported,
|
|
1691
|
+
received,
|
|
1692
|
+
fallback
|
|
1693
|
+
);
|
|
1694
|
+
}
|
|
1695
|
+
function assertExpectedBinding(typedDataDigest, expected, trustedSigner) {
|
|
1696
|
+
assertExpectedShape(expected);
|
|
1697
|
+
if (!trustedSigner) {
|
|
1698
|
+
throw new edge.HavenSigningError(
|
|
1699
|
+
"x402 expected-context verifier is not configured. Set HAVEN_X402_BINDING_SIGNER before signing x402 funding payloads."
|
|
1280
1700
|
);
|
|
1281
|
-
return { ok: false, hash, reason: "env_var_mismatch" };
|
|
1282
1701
|
}
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1702
|
+
if (expected.auth) {
|
|
1703
|
+
assertSupportedBindingVersion(
|
|
1704
|
+
expected.auth.version,
|
|
1705
|
+
SUPPORTED_X402_EXPECTED_VERSIONS,
|
|
1706
|
+
"x402 expected context"
|
|
1707
|
+
);
|
|
1287
1708
|
}
|
|
1288
|
-
if (
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1709
|
+
if (!expected.typedDataHash) {
|
|
1710
|
+
throw new edge.HavenSigningError(
|
|
1711
|
+
"Refusing to sign typed data under an expected context that does not commit to it. Haven must return a v2/v3 x402 expected context (with typedDataHash) for a delegation-rail intent."
|
|
1712
|
+
);
|
|
1713
|
+
}
|
|
1714
|
+
if (expected.typedDataHash.toLowerCase() !== typedDataDigest.toLowerCase()) {
|
|
1715
|
+
throw new edge.HavenSigningError(
|
|
1716
|
+
"x402 typed data does not match the digest Haven committed to in the expected context. Refusing to sign \u2014 the payload was altered in transit or Haven declared a different one. The most common cause is the typed data being truncated or reshaped while being copied between tool calls (#1255): re-run the hosted quote and pass its typed_data_b64 string through UNCHANGED instead of re-emitting the nested JSON."
|
|
1717
|
+
);
|
|
1718
|
+
}
|
|
1719
|
+
const message = edge.buildX402ExpectedMessage({
|
|
1720
|
+
paymentId: expected.paymentId,
|
|
1721
|
+
payloadHash: expected.payloadHash,
|
|
1722
|
+
resourceUrl: expected.resourceUrl,
|
|
1723
|
+
merchantTo: expected.merchantTo,
|
|
1724
|
+
amount: expected.amount,
|
|
1725
|
+
asset: expected.asset,
|
|
1726
|
+
network: expected.network,
|
|
1727
|
+
expiresAt: expected.expiresAt,
|
|
1728
|
+
typedDataHash: expected.typedDataHash,
|
|
1729
|
+
payerDelegate: expected.payerDelegate,
|
|
1730
|
+
payerAgentId: expected.payerAgentId
|
|
1731
|
+
});
|
|
1732
|
+
const expectedVersion = expected.payerDelegate ? 3 : 2;
|
|
1733
|
+
if (expected.auth?.version !== expectedVersion || expected.auth.message !== message) {
|
|
1734
|
+
throw new edge.HavenSigningError("x402 expected context authentication message is invalid.");
|
|
1735
|
+
}
|
|
1736
|
+
if (!sameAddress(expected.auth.signer, trustedSigner)) {
|
|
1737
|
+
throw new edge.HavenSigningError("x402 expected context was not signed by the configured Haven signer.");
|
|
1738
|
+
}
|
|
1739
|
+
if (!edge.verifySignature(viem.hashMessage(message), expected.auth.signature, trustedSigner)) {
|
|
1740
|
+
throw new edge.HavenSigningError("x402 expected context signature could not be verified.");
|
|
1295
1741
|
}
|
|
1296
|
-
out.write(renderSignerConsentBlock(input, hash));
|
|
1297
|
-
return { ok: false, hash, reason: "no_acknowledgement" };
|
|
1298
|
-
}
|
|
1299
|
-
function registeredSignerToolNames() {
|
|
1300
|
-
return Object.keys(toolSchemas);
|
|
1301
1742
|
}
|
|
1302
|
-
function
|
|
1303
|
-
if (!
|
|
1304
|
-
|
|
1743
|
+
function assertPayerMatchesDelegate(expected, delegateAddress, localAgentId) {
|
|
1744
|
+
if (!expected.payerDelegate) return;
|
|
1745
|
+
if (sameAddress(expected.payerDelegate, delegateAddress)) return;
|
|
1746
|
+
const quoteAgent = expected.payerAgentId ?? "unknown";
|
|
1747
|
+
const localAgent = localAgentId ?? "unknown";
|
|
1748
|
+
throw new edge.HavenSigningError(
|
|
1749
|
+
`This quote belongs to a DIFFERENT agent: the quote was created for agent ${quoteAgent} (delegate ${expected.payerDelegate}), but this signer holds agent ${localAgent} (delegate ${delegateAddress}). A long-lived host is holding stale credentials \u2014 its session authenticates as the old agent while the signer on disk belongs to the new one. Nothing was signed. Restart the host so it re-reads its wiring, then re-quote.`
|
|
1750
|
+
);
|
|
1305
1751
|
}
|
|
1306
|
-
async function
|
|
1752
|
+
async function loadX402Schemes() {
|
|
1307
1753
|
try {
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1754
|
+
return await import('x402/schemes');
|
|
1755
|
+
} catch (err) {
|
|
1756
|
+
throw new edge.HavenSigningError(
|
|
1757
|
+
`The x402 scheme module could not be loaded, so no merchant header was built (nothing was sent to the merchant): ${err instanceof Error ? err.message : String(err)}. The signer's runtime install is incomplete \u2014 run: npx @haven_ai/connect --doctor`
|
|
1758
|
+
);
|
|
1313
1759
|
}
|
|
1314
1760
|
}
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
)
|
|
1761
|
+
function assertX402PaymentWindowOpen(expected) {
|
|
1762
|
+
if (!expected.expiresAt) return;
|
|
1763
|
+
const expiresAtMs = Date.parse(expected.expiresAt);
|
|
1764
|
+
if (Number.isNaN(expiresAtMs)) {
|
|
1765
|
+
throw new edge.HavenSigningError("x402 expected context expiresAt is not a valid ISO timestamp.");
|
|
1766
|
+
}
|
|
1767
|
+
if (expiresAtMs <= Date.now()) {
|
|
1768
|
+
throw new edge.HavenError(
|
|
1769
|
+
"The x402 payment window expired before the merchant header could be signed. Re-quote with haven_pay_mcp_tool using the same idempotency_key before trying again.",
|
|
1770
|
+
edge.AgentPaymentFailureCode.PaymentWindowExpired,
|
|
1771
|
+
410,
|
|
1772
|
+
expected.paymentId
|
|
1773
|
+
);
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
function sameAddress(a, b) {
|
|
1777
|
+
return a.toLowerCase() === b.toLowerCase();
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
// src/capabilities.ts
|
|
1781
|
+
var SIGNER_CAPABILITY_KEY = "haven/signer-compatibility";
|
|
1782
|
+
function signerCompatibility() {
|
|
1783
|
+
return {
|
|
1784
|
+
x402_expected_context_versions: [...SUPPORTED_X402_EXPECTED_VERSIONS],
|
|
1785
|
+
sweep_binding_versions: [...SUPPORTED_SWEEP_BINDING_VERSIONS],
|
|
1786
|
+
direct_sign_context_versions: [...SUPPORTED_DIRECT_SIGN_CONTEXT_VERSIONS],
|
|
1787
|
+
task_sign_context_versions: [...SUPPORTED_TASK_SIGN_CONTEXT_VERSIONS]
|
|
1788
|
+
};
|
|
1789
|
+
}
|
|
1790
|
+
function signerCapabilityAdvertisement() {
|
|
1791
|
+
return { experimental: { [SIGNER_CAPABILITY_KEY]: signerCompatibility() } };
|
|
1792
|
+
}
|
|
1793
|
+
function signerInstructions() {
|
|
1794
|
+
const compatibility = signerCompatibility();
|
|
1795
|
+
return [
|
|
1796
|
+
"Haven edge signer: sign-only tools bound to the local delegate key. It never emits the",
|
|
1797
|
+
"key. Its one network capability is an authenticated READ of a signing context from",
|
|
1798
|
+
"Haven by payment_id \u2014 pass payment_id to haven_sign (preferred for both a direct payment,",
|
|
1799
|
+
"#3271, and delegation-rail x402) or haven_sign_x402 (x402 only) instead of relaying bulky",
|
|
1800
|
+
"typed-data payloads yourself.",
|
|
1801
|
+
"",
|
|
1802
|
+
"Version compatibility (check this BEFORE signing, not after):",
|
|
1803
|
+
`- x402 expected-context versions supported: ${compatibility.x402_expected_context_versions.join(", ")}`,
|
|
1804
|
+
`- sweep authorization binding versions supported: ${compatibility.sweep_binding_versions.join(", ")}`,
|
|
1805
|
+
`- direct-payment (haven_send / haven_pay) sign-context versions supported: ${compatibility.direct_sign_context_versions.join(", ")} \u2014 pass payment_id alone to haven_sign; this signer fetches the exact bytes`,
|
|
1806
|
+
`- task-budget sign-context versions supported: ${compatibility.task_sign_context_versions.join(", ")} \u2014 pass task_budget_id alone to haven_sign; this signer fetches the exact bytes`,
|
|
1807
|
+
"",
|
|
1808
|
+
"Haven quote and prepare results report the expected-context version they will emit",
|
|
1809
|
+
"(signer_compatibility.x402_expected_context_version). If that version is not in the list",
|
|
1810
|
+
"above, this signer is out of date: STOP before signing, and tell the user to update",
|
|
1811
|
+
`@haven_ai/signer by rerunning \`${edge.connectorRerunCommand()}\`, which reinstalls the pinned`,
|
|
1812
|
+
"MCP runtime. Do not edit the version field to a supported value \u2014 it is part of the",
|
|
1813
|
+
"Haven-signed binding message, so changing it invalidates the signature.",
|
|
1814
|
+
"",
|
|
1815
|
+
"A version-mismatch refusal from haven_sign / haven_sign_x402 / haven_sign_sweep_delegate is",
|
|
1816
|
+
"machine-readable, not just prose: it carries code, supported_versions, received_version, and",
|
|
1817
|
+
"fallback fields alongside the message, so you can branch on it directly. Every signer",
|
|
1818
|
+
"refusal also carries the next-step family: next_tool_name + next_tool_server_role when a",
|
|
1819
|
+
"hosted tool follows (resolve the role against your own server names), else",
|
|
1820
|
+
"next_tool_omitted_reason saying why not."
|
|
1821
|
+
].join("\n");
|
|
1322
1822
|
}
|
|
1323
1823
|
async function loadSignerCredentials(path = process.env.HAVEN_CREDENTIALS) {
|
|
1324
1824
|
if (path) return loadFromFile(path);
|
|
@@ -1416,24 +1916,12 @@ function invalidChainIdError(label) {
|
|
|
1416
1916
|
}
|
|
1417
1917
|
async function warnIfCredentialFilePermissive(path, log = (message) => process.stderr.write(`${message}
|
|
1418
1918
|
`), platform = process.platform) {
|
|
1419
|
-
|
|
1420
|
-
let mode;
|
|
1421
|
-
try {
|
|
1422
|
-
mode = (await promises.stat(path)).mode;
|
|
1423
|
-
} catch {
|
|
1424
|
-
return;
|
|
1425
|
-
}
|
|
1426
|
-
if ((mode & 63) !== 0) {
|
|
1427
|
-
const octal = (mode & 511).toString(8).padStart(4, "0");
|
|
1428
|
-
log(
|
|
1429
|
-
`haven-signer: warning: credential file at ${path} is readable beyond the owner (mode ${octal}). Run: chmod 600 ${path}`
|
|
1430
|
-
);
|
|
1431
|
-
}
|
|
1919
|
+
await warnIfFilePermissive("credential file", path, log, platform);
|
|
1432
1920
|
}
|
|
1433
1921
|
|
|
1434
1922
|
// src/server.ts
|
|
1435
1923
|
var SIGNER_NAME = "@haven_ai/signer";
|
|
1436
|
-
var SIGNER_VERSION = "0.
|
|
1924
|
+
var SIGNER_VERSION = "0.6.0-alpha.0";
|
|
1437
1925
|
async function resolveSignerRuntime(options = {}) {
|
|
1438
1926
|
assertSupportedNodeVersion(options.nodeVersion);
|
|
1439
1927
|
if (options.delegateKey) {
|
|
@@ -1471,13 +1959,17 @@ function buildSignerMcpServer(signer, options = {}) {
|
|
|
1471
1959
|
accountAddress: options.credentials?.accountAddress,
|
|
1472
1960
|
chainId: options.credentials?.chainId
|
|
1473
1961
|
},
|
|
1474
|
-
// #1263: the payment_id signing path — the ONLY network
|
|
1475
|
-
//
|
|
1962
|
+
// #1263: the payment_id signing path — the ONLY network path this server
|
|
1963
|
+
// has: up to two authenticated reads of a signing context from Haven (x402,
|
|
1964
|
+
// then direct for haven_sign, #3271), using
|
|
1476
1965
|
// the agent identity the connector stores next to the signer credential.
|
|
1477
1966
|
// The signer CORE stays network-free; fetched bytes still pass the same
|
|
1478
1967
|
// binding verification + digest re-derivation as tool-argument bytes.
|
|
1479
1968
|
signContext: {
|
|
1480
|
-
loadIdentity: () => loadHavenIdentity(credentialsPath)
|
|
1969
|
+
loadIdentity: () => loadHavenIdentity(credentialsPath),
|
|
1970
|
+
// #3303: the backend refuses a signer below a minimum it has set, at
|
|
1971
|
+
// sign-context — the one place this signer meets it.
|
|
1972
|
+
clientIdentity: edge.havenClientIdentity(SIGNER_NAME, SIGNER_VERSION)
|
|
1481
1973
|
}
|
|
1482
1974
|
});
|
|
1483
1975
|
const registerTool = server.tool.bind(server);
|
|
@@ -1492,9 +1984,9 @@ function buildSignerMcpServer(signer, options = {}) {
|
|
|
1492
1984
|
return server;
|
|
1493
1985
|
}
|
|
1494
1986
|
function assertSupportedNodeVersion(nodeVersion = process.versions.node) {
|
|
1495
|
-
if (
|
|
1987
|
+
if (edge.isSupportedNodeVersion(nodeVersion)) return;
|
|
1496
1988
|
const err = new Error(
|
|
1497
|
-
|
|
1989
|
+
edge.unsupportedNodeVersionMessage({ subject: "The Haven signer", nodeVersion })
|
|
1498
1990
|
);
|
|
1499
1991
|
err.code = "HAVEN_SIGNER_UNSUPPORTED_NODE";
|
|
1500
1992
|
throw err;
|
|
@@ -1505,7 +1997,7 @@ async function runSignerStdioServer(options = {}) {
|
|
|
1505
1997
|
const decision = await runSignerConsentGate(signer, credentials, options);
|
|
1506
1998
|
if (!decision.ok) {
|
|
1507
1999
|
const err = new Error(
|
|
1508
|
-
decision.reason === "env_var_mismatch" ? "Haven edge signer consent acknowledgement does not match the current configuration." : "Haven edge signer requires a one-time consent acknowledgement before starting."
|
|
2000
|
+
(decision.reason === "env_var_mismatch" ? "Haven edge signer consent acknowledgement does not match the current configuration." : "Haven edge signer requires a one-time consent acknowledgement before starting.") + " See the consent block above; if the Haven connector wired this signer, run: npx @haven_ai/connect --doctor"
|
|
1509
2001
|
);
|
|
1510
2002
|
err.code = "HAVEN_SIGNER_NO_CONSENT";
|
|
1511
2003
|
throw err;
|
|
@@ -1540,49 +2032,18 @@ function toMcpResult(payload) {
|
|
|
1540
2032
|
}
|
|
1541
2033
|
|
|
1542
2034
|
// src/cli.ts
|
|
1543
|
-
function parseArgs(argv) {
|
|
1544
|
-
const options = {};
|
|
1545
|
-
for (let i = 0; i < argv.length; i += 1) {
|
|
1546
|
-
const arg = argv[i];
|
|
1547
|
-
if (arg === "--credentials" || arg === "--credentials-path") {
|
|
1548
|
-
options.credentialsPath = argv[i + 1];
|
|
1549
|
-
i += 1;
|
|
1550
|
-
} else if (arg === "--ack") {
|
|
1551
|
-
options.writeAck = true;
|
|
1552
|
-
} else if (arg === "--help" || arg === "-h") {
|
|
1553
|
-
process.stdout.write(
|
|
1554
|
-
[
|
|
1555
|
-
"Haven edge signer (local, holds the delegate key)",
|
|
1556
|
-
"",
|
|
1557
|
-
"Runs a local stdio MCP server exposing sign-only tools (haven_sign,",
|
|
1558
|
-
"haven_x402_sign_header). Pair it with the hosted, keyless Haven MCP",
|
|
1559
|
-
"server: the hosted server constructs and relays, this one signs.",
|
|
1560
|
-
"",
|
|
1561
|
-
"Usage:",
|
|
1562
|
-
" npx @haven_ai/signer --credentials /path/to/agent.json",
|
|
1563
|
-
"",
|
|
1564
|
-
"Options:",
|
|
1565
|
-
" --credentials <path> Haven credential JSON (delegate_key is read from it).",
|
|
1566
|
-
" Also supported: HAVEN_CREDENTIALS, or HAVEN_DELEGATE_KEY.",
|
|
1567
|
-
" --ack Acknowledge the first-launch consent block and write",
|
|
1568
|
-
" a signer sidecar acknowledgement next to the credential.",
|
|
1569
|
-
"",
|
|
1570
|
-
"Consent:",
|
|
1571
|
-
" On first launch the signer prints the sign-only tool list and delegate",
|
|
1572
|
-
" address, then refuses to start unless acknowledged. Its only Haven API call",
|
|
1573
|
-
" is a read-only fetch of the signing payload for one pending payment, so it",
|
|
1574
|
-
" cannot show a live allowance summary.",
|
|
1575
|
-
" Acknowledge with EITHER --ack OR HAVEN_SIGNER_ACK=<hash> in your environment.",
|
|
1576
|
-
""
|
|
1577
|
-
].join("\n")
|
|
1578
|
-
);
|
|
1579
|
-
process.exit(0);
|
|
1580
|
-
}
|
|
1581
|
-
}
|
|
1582
|
-
return options;
|
|
1583
|
-
}
|
|
1584
2035
|
async function main() {
|
|
1585
|
-
|
|
2036
|
+
const decision = parseSignerArgs(process.argv.slice(2));
|
|
2037
|
+
if (decision.kind === "help") {
|
|
2038
|
+
process.stdout.write(decision.text);
|
|
2039
|
+
process.exit(0);
|
|
2040
|
+
}
|
|
2041
|
+
if (decision.kind === "unknown-option") {
|
|
2042
|
+
process.stderr.write(`${decision.text}
|
|
2043
|
+
`);
|
|
2044
|
+
process.exit(2);
|
|
2045
|
+
}
|
|
2046
|
+
await runSignerStdioServer(decision.options);
|
|
1586
2047
|
}
|
|
1587
2048
|
main().catch((err) => {
|
|
1588
2049
|
process.stderr.write(`${err instanceof Error ? err.message : String(err)}
|