a2a-exposed 0.8.28 → 0.8.29
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/lib/link-finish.mjs +78 -16
- package/lib/main.mjs +2 -0
- package/package.json +1 -1
- package/worker/src/version.ts +1 -1
package/lib/link-finish.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { readSigningSecret, validateSecretSource, writeTokenFile, startConnector
|
|
|
8
8
|
|
|
9
9
|
const sleepDefault = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
10
|
const ERRORS = {
|
|
11
|
+
expired_token: "The link request expired. Start a new one with hosted link create.",
|
|
11
12
|
unauthorized: "Run a2a-exposed login, then run finish again.",
|
|
12
13
|
invalid_request: "Check the agent name, hostname and link options before running the command again.",
|
|
13
14
|
link_exists: "The link request was refused; review the approval page before changing or removing an existing link.",
|
|
@@ -18,10 +19,45 @@ const ERRORS = {
|
|
|
18
19
|
provision_failed: "Check the approval page for the failing provisioning phase and cleanup state before running the command again.",
|
|
19
20
|
preset: "Select a valid wake preset with --preset <id>, then run finish again.",
|
|
20
21
|
signing_secret: "Check the local signing-secret source and receiver configuration before running the command again.",
|
|
21
|
-
unreadable: "
|
|
22
|
+
unreadable: "The secure link access key could not be read. Remove the secure link on the website, then run create again.",
|
|
23
|
+
slow_down: "Too many pending requests for this agent. Deny an earlier request on the website or wait for it to expire, then run finish again.",
|
|
24
|
+
access_denied: "The request was denied. Run finish again only if you still want to set up this link.",
|
|
25
|
+
link_not_ready: "The secure link is not ready. Finish creating it in Wake settings on the website, then run finish again.",
|
|
26
|
+
invalid_grant: "This grant is not valid. Run finish again and approve a new request.",
|
|
27
|
+
already_delivered: "Credentials were already delivered for this request. Run finish again for fresh approval if you did not receive them.",
|
|
28
|
+
connector_not_connected: "The connector is not connected. Start cloudflared with the saved token file, then run finish again for fresh approval.",
|
|
29
|
+
not_found: "That agent or request was not found. Check --agent on the website, then run finish again.",
|
|
30
|
+
cloudflare_unavailable: "Cloudflare could not be reached. Reconnect Cloudflare in Wake settings on the website, then run finish again.",
|
|
22
31
|
unavailable: "The service is temporarily unavailable; retry later with fresh approval.",
|
|
23
32
|
};
|
|
24
|
-
|
|
33
|
+
|
|
34
|
+
// Fixed local guidance only: wake results must never expose server text.
|
|
35
|
+
const WAKE_NEXT_STEPS = {
|
|
36
|
+
none: "Update the hosted Worker, then run finish again for fresh approval.",
|
|
37
|
+
"dns-missing": "Wait for DNS propagation, then run finish again for fresh approval.",
|
|
38
|
+
"access-401": "Check the signing secret and Access service-token pair, then run finish again for fresh approval.",
|
|
39
|
+
"access-403": "Check the signing secret and Access service-token pair, then run finish again for fresh approval.",
|
|
40
|
+
timeout: "Retry later by running finish again for fresh approval.",
|
|
41
|
+
"origin-429": "Retry later by running finish again for fresh approval.",
|
|
42
|
+
"origin-5xx": "Retry later by running finish again for fresh approval.",
|
|
43
|
+
"origin-NNN": "Check the receiver's HTTP response, then run finish again for fresh approval.",
|
|
44
|
+
tls: "Fix the receiver's TLS certificate, then run finish again for fresh approval.",
|
|
45
|
+
private: "Use a public HTTPS wake URL, then run finish again for fresh approval.",
|
|
46
|
+
dns: "Check the wake hostname's DNS records, then run finish again for fresh approval.",
|
|
47
|
+
connection: "Check that the receiver is reachable, then run finish again for fresh approval.",
|
|
48
|
+
unreadable: "Remove the secure link on the website, then run create again.",
|
|
49
|
+
failed: "Check the receiver configuration, then run finish again for fresh approval.",
|
|
50
|
+
unknown: "Update the CLI (npx a2a-exposed@latest) and send feedback.",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function wakeFailure(raw) {
|
|
54
|
+
const value = typeof raw === "string" ? raw : "";
|
|
55
|
+
const normalized = /^[1-5]\d{2}$/.test(value) ? `origin-${value}` : value;
|
|
56
|
+
const result = Object.hasOwn(WAKE_NEXT_STEPS, normalized) && !["origin-5xx", "origin-NNN"].includes(normalized)
|
|
57
|
+
? normalized : /^origin-\d{3}$/.test(normalized) ? normalized : "unknown";
|
|
58
|
+
const guidance = WAKE_NEXT_STEPS[result] ?? WAKE_NEXT_STEPS[/^origin-5\d{2}$/.test(result) ? "origin-5xx" : "origin-NNN"];
|
|
59
|
+
return { result, message: `wake test of the new secure link failed (${result}); saved wake settings were not changed. ${guidance}` };
|
|
60
|
+
}
|
|
25
61
|
|
|
26
62
|
// Only these public validation templates may cross the server-text boundary.
|
|
27
63
|
// Never echo arbitrary bodies, fetch errors, child output, or credential-bearing fields.
|
|
@@ -34,23 +70,47 @@ const VALIDATION_MESSAGES = new Set([
|
|
|
34
70
|
"Use an http or https origin with no path or credentials.",
|
|
35
71
|
"Use a path beginning with /, with no query or fragment.",
|
|
36
72
|
]);
|
|
73
|
+
const DOMAIN = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
|
37
74
|
function validationDetail(body, hostname) {
|
|
38
75
|
if (typeof body?.message !== "string") return "";
|
|
39
76
|
if (VALIDATION_MESSAGES.has(body.message)) return body.message.replace(/ and try again\.$/, ".");
|
|
77
|
+
if (body.reason === "outside_zone" || body.error === "outside_zone") {
|
|
78
|
+
const prefix = "That hostname is not on a domain in your Cloudflare account. ";
|
|
79
|
+
if (body.message === prefix + "Add a domain to Cloudflare, then run create again.") return body.message;
|
|
80
|
+
const match = body.message.match(/^That hostname is not on a domain in your Cloudflare account\. Use a hostname under (.+)\.$/);
|
|
81
|
+
if (match && match[1].split(" or ").every((domain) => DOMAIN.test(domain))) return body.message;
|
|
82
|
+
}
|
|
40
83
|
const apex = `Use a hostname under ${hostname}, such as wake.${hostname}. The domain itself is not a secure link.`;
|
|
41
84
|
return hostname && body.message === apex ? apex : "";
|
|
42
85
|
}
|
|
43
86
|
|
|
44
|
-
function
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
87
|
+
function provisionGuidance(body) {
|
|
88
|
+
if (body.phase === "cleanup") return "Remove the unfinished secure link on the website, then run create again.";
|
|
89
|
+
if (["hostname", "zone", "existing_dns", "existing_tunnel"].includes(body.phase))
|
|
90
|
+
return "Choose another hostname on a domain in your Cloudflare account, then run create again.";
|
|
91
|
+
if (body.status === 401 || body.status === 403)
|
|
92
|
+
return "Cloudflare refused access. Reconnect Cloudflare on the website, then run create again.";
|
|
93
|
+
if (body.status === 429 || (Number.isInteger(body.status) && body.status >= 500 && body.status <= 599))
|
|
94
|
+
return "Cloudflare was busy. Run create again in a few minutes.";
|
|
95
|
+
return ERRORS.provision_failed;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function failure(status, body, { command, hostname }, phase = "requests") {
|
|
99
|
+
const known = (value) => typeof value === "string" && Object.hasOwn(ERRORS, value);
|
|
100
|
+
const code = known(body?.reason) ? body.reason : known(body?.error) ? body.error : `HTTP ${status}`;
|
|
101
|
+
const detail = phase === "requests" && ["invalid_request", "apex_hostname", "outside_zone"].includes(code) ? validationDetail(body, hostname) : "";
|
|
102
|
+
let guidance = code === "provision_failed" ? provisionGuidance(body) : Object.hasOwn(ERRORS, code) ? ERRORS[code] : "check the link on the website and rerun the command for fresh approval";
|
|
103
|
+
if (code === "expired_token") guidance = ["grant", "connector"].includes(phase)
|
|
104
|
+
? `The approval grant expired. Run hosted link finish again for fresh approval; ${phase === "grant" ? "the saved connector token remains on disk and " : ""}wake settings were not saved.`
|
|
105
|
+
: `The link request expired. Start a new one with hosted link ${command}.`;
|
|
106
|
+
if (code === "invalid_request" && phase === "token") guidance = `The approval polling request was refused. Run hosted link ${command} again for fresh approval.`;
|
|
107
|
+
const message = detail || guidance.replace(/run finish/gi, (match) => `${match.startsWith("R") ? "Run" : "run"} ${command}`);
|
|
48
108
|
return new CliError(`link ${command} failed${hostname ? ` for ${hostname}` : ""} (${code}); ${message}`);
|
|
49
109
|
}
|
|
50
110
|
|
|
51
|
-
export function savedLinkSession(origin, session = C.controlSession()) {
|
|
111
|
+
export function savedLinkSession(origin, session = C.controlSession(), command = "finish") {
|
|
52
112
|
if (controlOrigin(session.origin) !== origin || !session.token)
|
|
53
|
-
throw new CliError(
|
|
113
|
+
throw new CliError(`sign in to this service first: run login --control-url <service-origin>, then rerun hosted link ${command}; environment sessions require both CONTROL_URL and CONTROL_TOKEN`);
|
|
54
114
|
return session.token;
|
|
55
115
|
}
|
|
56
116
|
|
|
@@ -100,7 +160,7 @@ async function setupLink(o, {
|
|
|
100
160
|
if (o.preset !== undefined && (typeof o.preset !== "string" || !/^[a-z][a-z0-9-]{0,63}$/.test(o.preset))) throw new CliError("--preset must be a wake preset id");
|
|
101
161
|
const origin = controlOrigin(o["control-url"]);
|
|
102
162
|
if (!origin) throw new CliError("--control-url must be an https origin with no path");
|
|
103
|
-
const session = sessionToken(origin);
|
|
163
|
+
const session = sessionToken(origin, undefined, context.command);
|
|
104
164
|
const request = async (endpoint, body, grant, timeout = 15000) => {
|
|
105
165
|
let response;
|
|
106
166
|
try {
|
|
@@ -134,10 +194,10 @@ async function setupLink(o, {
|
|
|
134
194
|
const token = await request("token", { request_id: setup.request_id, verifier });
|
|
135
195
|
if (token.response.status === 428 && token.body.error === "authorization_pending") continue;
|
|
136
196
|
if (token.response.status === 429 && token.body.error === "slow_down") { interval += 5; continue; }
|
|
137
|
-
if (!token.response.ok) throw failure(token.response.status, token.body, context);
|
|
197
|
+
if (!token.response.ok) throw failure(token.response.status, token.body, context, "token");
|
|
138
198
|
authorized = token.body; break;
|
|
139
199
|
}
|
|
140
|
-
if (!authorized) throw failure(410, { error: "expired_token" }, context);
|
|
200
|
+
if (!authorized) throw failure(410, { error: "expired_token" }, context, "token");
|
|
141
201
|
const { grant, link } = authorized;
|
|
142
202
|
if (typeof grant !== "string" || !/^[\x21-\x7e]+$/.test(grant) || grant.length > 4096 || link?.agent !== agent || typeof link.path !== "string" || !link.path.startsWith("/") || typeof link.preset !== "string" || typeof link.hostname !== "string" || typeof link.origin !== "string")
|
|
143
203
|
throw new CliError("invalid approved link response");
|
|
@@ -148,7 +208,7 @@ async function setupLink(o, {
|
|
|
148
208
|
// Resolve and validate the source before spending the one-time connector delivery.
|
|
149
209
|
const secret = readSecret(o, { ...link, preset });
|
|
150
210
|
const connector = await request("connector", {}, grant);
|
|
151
|
-
if (!connector.response.ok) throw failure(connector.response.status, connector.body, context);
|
|
211
|
+
if (!connector.response.ok) throw failure(connector.response.status, connector.body, context, "connector");
|
|
152
212
|
if (typeof connector.body.connector_token !== "string" || !connector.body.connector_token.trim() || /\s/.test(connector.body.connector_token)) throw new CliError("invalid connector response; run finish again for fresh approval");
|
|
153
213
|
const tokenFile = path.resolve(o["token-file"] || path.join(os.homedir(), ".a2a-exposed", "links", `${agent}.token`));
|
|
154
214
|
saveToken(tokenFile, connector.body.connector_token);
|
|
@@ -162,17 +222,19 @@ async function setupLink(o, {
|
|
|
162
222
|
for (let poll = 0; poll < 20 && now() < connectDeadline; poll++) {
|
|
163
223
|
const status = await request("status", undefined, grant, Math.min(15000, connectDeadline - now()));
|
|
164
224
|
if (now() >= connectDeadline) break;
|
|
165
|
-
if (!status.response.ok) throw failure(status.response.status, status.body, context);
|
|
225
|
+
if (!status.response.ok) throw failure(status.response.status, status.body, context, "grant");
|
|
166
226
|
if (status.body.connector === "connected" && Number.isInteger(status.body.connections) && status.body.connections > 0) { connected = true; break; }
|
|
167
227
|
await sleep(Math.min(3000, Math.max(0, connectDeadline - now())));
|
|
168
228
|
}
|
|
169
229
|
if (!connected) throw new CliError("connector did not report connected within 60 seconds; check cloudflared and run finish again for fresh approval");
|
|
170
230
|
const tested = await request("wake-test", { signing_secret: secret, preset }, grant);
|
|
171
|
-
if (!tested.response.ok) throw failure(tested.response.status, tested.body, context);
|
|
231
|
+
if (!tested.response.ok) throw failure(tested.response.status, tested.body, context, "grant");
|
|
172
232
|
if (tested.body.result !== "ok" || tested.body.saved !== true) {
|
|
173
|
-
const result
|
|
174
|
-
|
|
233
|
+
const { result, message } = wakeFailure(tested.body.result);
|
|
234
|
+
write(`wake_result=${result}`);
|
|
235
|
+
throw new CliError(message);
|
|
175
236
|
}
|
|
237
|
+
write("wake_result=ok");
|
|
176
238
|
write("Connector connected. Wake test passed and settings saved.");
|
|
177
239
|
return { saved: true, connectorStarted: true };
|
|
178
240
|
}
|
package/lib/main.mjs
CHANGED
|
@@ -115,6 +115,8 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
|
|
|
115
115
|
wake fingerprint fingerprints (first 12 hex of sha256) of WAKE_* exported in this shell
|
|
116
116
|
wake test send a test wake now and print the HTTP status
|
|
117
117
|
wake unset remove wake secrets (fall back to polling)
|
|
118
|
+
handler prompts save autonomous, review-all or escalate mode and owner notices;
|
|
119
|
+
see docs/receiving-messages.md for setup and the every 6h webhook fallback
|
|
118
120
|
|
|
119
121
|
Tunnel (local-only webhooks: OpenClaw gateway, Hermes, ...; needs Zero Trust/Access and a Cloudflare zone
|
|
120
122
|
anywhere on the account: the A2A endpoint itself can be on workers.dev or a custom hostname)
|
package/package.json
CHANGED
package/worker/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Inbox build. Keep this equal to `version` in cli/package.json. */
|
|
2
|
-
export const VERSION = "0.8.
|
|
2
|
+
export const VERSION = "0.8.29";
|