a2a-exposed 0.8.32 → 0.8.34

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/hosted.mjs CHANGED
@@ -4,11 +4,23 @@ import * as C from "./config.mjs";
4
4
  import { controlOrigin, deviceLogin } from "./account.mjs";
5
5
  import { CliError, upstreamUrlProblem } from "./a2a.mjs";
6
6
 
7
- export const SETUP_SCOPES = ["agents:create", "agents:owner-token", "wake:write", "wake:test"];
7
+ export const SETUP_SCOPES = ["agents:read", "agents:create", "agents:owner-token", "wake:write", "wake:test"];
8
8
  const NEXT = "Run hosted capabilities --json to list scopes and presets; login --machine NAME --scopes SCOPES to approve setup.";
9
9
  const fail = (code, next) => { const error = new CliError(next); error.result = { ok: false, code, next }; throw error; };
10
10
  const printable = value => typeof value === "string" && value.length <= 4096 && !/[\x00-\x1f\x7f]/.test(value);
11
11
 
12
+ function retryAfter(response, data) {
13
+ const header = response.headers.get("retry-after");
14
+ let seconds;
15
+ if (header && /^\d+$/.test(header)) seconds = Number(header);
16
+ else if (header && /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT$/.test(header)) {
17
+ const date = Date.parse(header);
18
+ if (Number.isFinite(date)) seconds = Math.max(0, Math.ceil((date - Date.now()) / 1000));
19
+ }
20
+ if (Number.isSafeInteger(seconds) && seconds >= 0) return seconds;
21
+ return Number.isSafeInteger(data.retry_after) && data.retry_after >= 0 ? data.retry_after : undefined;
22
+ }
23
+
12
24
  export function setupSession(env = process.env, saved = C.fileConfig()) {
13
25
  const source = env.HOSTED_SETUP_URL || env.HOSTED_SETUP_TOKEN ? env : saved;
14
26
  return { origin: controlOrigin(source.HOSTED_SETUP_URL), token: source.HOSTED_SETUP_TOKEN || "" };
@@ -32,10 +44,21 @@ async function request(origin, path, { token = "", method = "GET", body, fetchIm
32
44
  } catch { fail("unavailable", "Hosted setup did not answer with JSON. Check the service and try again. Do not repeat a create request until hosted list confirms its result."); }
33
45
  if (!data || typeof data !== "object" || Array.isArray(data)) fail("invalid_response", "The hosted service returned an invalid setup response.");
34
46
  if (!response.ok) {
35
- const known = ["taken", "limit", "invalid_name", "reserved_name", "invalid_grant", "insufficient_scope", "conflict", "unavailable", "not_found", "test", "signing", "unreadable", "preset", "url", "header", "access", "invalid_reference"];
47
+ if (response.status === 429 && data.error === "too_many_references") {
48
+ const seconds = retryAfter(response, data);
49
+ const next = seconds === undefined
50
+ ? "Wait for an unused reference to expire before running wake prepare again; no automatic retry was made."
51
+ : `Wait ${seconds} seconds before running wake prepare again with a new --reference-file; no automatic retry was made.`;
52
+ // Retain the capacity message, but never reflect arbitrary service text or credentials.
53
+ const message = typeof data.message === "string" && /^This setup grant has \d{1,10} unused references\. Wait \d{1,10} seconds before uploading another draft\.$/.test(data.message) ? data.message : undefined;
54
+ const error = new CliError(next);
55
+ error.result = { ok: false, code: "too_many_references", next, ...(message ? { message } : {}), ...(seconds === undefined ? {} : { retry_after: seconds }) };
56
+ throw error;
57
+ }
58
+ const known = ["taken", "limit", "invalid_name", "reserved_name", "invalid_grant", "insufficient_scope", "conflict", "unavailable", "not_found", "test", "signing", "unreadable", "preset", "url", "header", "access", "invalid_reference", "body"];
36
59
  const code = known.includes(data.error) ? data.error : `http_${response.status}`;
37
- const next = code === "taken" || code === "reserved_name" ? "Choose another --name and run hosted list --json before creating."
38
- : code === "limit" ? "The account is at its agent limit. Review hosted list --json and account plans."
60
+ const next = code === "taken" || code === "reserved_name" ? "Choose another --name using hosted names --name NAME --json before creating."
61
+ : code === "limit" ? "The account is at its agent limit. Read hosted capacity --json and review account plans."
39
62
  : code === "conflict" ? "Wake settings changed. Run wake preview --json, then test the draft again."
40
63
  : code === "invalid_reference" ? "The wake reference was already used, expired or does not match this draft. Run wake prepare again with the draft inputs and a new --reference-file."
41
64
  : code === "invalid_grant" || code === "insufficient_scope" ? NEXT
@@ -48,7 +71,7 @@ async function request(origin, path, { token = "", method = "GET", body, fetchIm
48
71
  export async function capabilities(o = {}) {
49
72
  const data = await request(originFor(o), "/api/v1/setup/capabilities");
50
73
  if (!Array.isArray(data.scopes) || !Array.isArray(data.wake?.presets)) fail("unsupported", "This service does not expose hosted setup capabilities yet.");
51
- return { ok: true, code: "capabilities", next: "Select explicit scopes and a wake preset from this listing.", scopes: data.scopes.filter(s => SETUP_SCOPES.includes(s)), presets: data.wake.presets.filter(s => /^[a-z-]+$/.test(s)), secret_reference: data.wake.secret_reference === true, secure_link_create: data.wake.secure_link_create === true };
74
+ return { ok: true, code: "capabilities", next: "Select explicit scopes and a wake preset from this listing.", scopes: data.scopes.filter(s => SETUP_SCOPES.includes(s)), presets: data.wake.presets.filter(s => /^[a-z-]+$/.test(s)), preflight: data.preflight === true, secret_reference: data.wake.secret_reference === true, wake_removal: data.wake.removal === true, secure_link_create: data.wake.secure_link_create === true };
52
75
  }
53
76
 
54
77
  export async function login(o = {}) {
@@ -75,12 +98,37 @@ export async function list(o = {}) {
75
98
  return { ok: true, code: "agents", next: "This listing contains hosted agents only; Cloudflare-linked Workers remain in dashboard Your agents. Select an agent, or run hosted create --name NAME --json.", agents: data.tenants.map(row => ({ name: String(row.name || ""), status: String(row.status || ""), version: row.version })) };
76
99
  }
77
100
 
101
+ export async function names(o = {}) {
102
+ const name = String(o.name || "").trim().toLowerCase();
103
+ if (!/^[a-z0-9][a-z0-9-]{2,30}[a-z0-9]$/.test(name)) fail("invalid_name", "Pass one explicit --name of 4–32 letters, numbers or hyphens, starting and ending with a letter or number. Use hosted names --name NAME --json before approving setup.");
104
+ const origin = originFor(o);
105
+ if (!(await capabilities(o)).preflight) fail("unsupported", "This service does not expose name preflight. Use hosted capabilities --json or the website.");
106
+ const data = await request(origin, `/api/v1/setup/name?name=${encodeURIComponent(name)}`);
107
+ if (data.name !== name || typeof data.available !== "boolean" || !["available", "invalid_name", "reserved_name", "taken"].includes(data.reason) || data.available !== (data.reason === "available")) fail("invalid_response", "The service did not return a valid name availability result. No creation was attempted.");
108
+ return { ok: true, code: "name_availability", name, available: data.available, reason: data.reason, next: data.available ? "This name is available now, without a reservation. Run hosted capacity --json before hosted create --name NAME --json." : "Choose another --name and repeat hosted names --name NAME --json before requesting creation access." };
109
+ }
110
+
111
+ export async function capacity(o = {}) {
112
+ const { origin, token } = accountCredential(o);
113
+ if (!(await capabilities(o)).preflight) fail("unsupported", "This service does not expose capacity preflight. Use hosted capabilities --json or the website.");
114
+ const data = await request(origin, "/api/v1/setup/capacity", { token });
115
+ const count = value => Number.isSafeInteger(value) && value >= 0;
116
+ if (!(data.limit === null || count(data.limit)) || !count(data.used) || !(data.remaining === null || count(data.remaining)) || data.remaining !== (data.limit === null ? null : Math.max(0, data.limit - data.used)) || data.can_create !== (data.remaining === null || data.remaining > 0)) fail("invalid_response", "The service did not return valid account capacity. No creation was attempted.");
117
+ return { ok: true, code: "capacity", limit: data.limit, used: data.used, remaining: data.remaining, can_create: data.can_create, next: data.can_create ? "This is a read-only snapshot. Check hosted names --name NAME --json, then explicitly approve creation scopes if needed and run hosted create --name NAME --json." : "The account has no creation room. Review its agents and plan before another creation attempt." };
118
+ }
119
+
78
120
  export async function create(o = {}) {
79
121
  const name = String(o.name || "").trim().toLowerCase();
80
122
  if (!/^[a-z0-9][a-z0-9-]{2,30}[a-z0-9]$/.test(name)) fail("invalid_name", "Pass --name with 4–32 letters, numbers or hyphens, starting and ending with a letter or number. Run hosted list --json first.");
81
123
  // Never replace an existing deployment or an environment-selected credential.
82
124
  if (C.get("A2A_BASE_URL") || C.get("A2A_OWNER_TOKEN") || C.get("A2A_WORKER_NAME") || C.get("A2A_D1_ID")) fail("config_in_use", "Use --config-dir with a separate directory for the new agent; keep the existing deployment configuration.");
83
125
  const { origin, token } = accountCredential(o);
126
+ const service = await capabilities(o);
127
+ if (service.preflight) {
128
+ const available = await names(o);
129
+ if (!available.available) fail(available.reason, "Choose a name with hosted names --name NAME --json before creating.");
130
+ if (!(await capacity(o)).can_create) fail("limit", "The account is at its agent limit. Read hosted capacity --json and review its agents or plan.");
131
+ }
84
132
  const before = await list(o);
85
133
  if (before.agents.some(row => row.name === name && row.status !== "deleted")) fail("taken", "That name already belongs to this account. Select it from hosted list --json or choose another --name.");
86
134
  const data = await request(origin, "/api/v1/tenants", { token, method: "POST", body: { name } });
@@ -146,14 +194,20 @@ export async function status(o = {}) {
146
194
 
147
195
  export async function wake(sub, o = {}) {
148
196
  if (!["preview", "test", "set", "prepare", "remove", "unset"].includes(sub)) fail("usage", "Use wake preview|test|set|prepare|remove. List presets with hosted capabilities --json.");
149
- if (sub === "remove" || sub === "unset") fail("unsupported", "This hosted API does not support wake removal yet. Remove it in the agent's Wake settings; saved settings were not changed.");
197
+ const removing = sub === "remove" || sub === "unset";
198
+ const preparingRemoval = sub === "prepare" && o.clear === true;
199
+ if (o.clear && !preparingRemoval) fail("usage", "Use wake prepare --clear for an MCP removal reference, or wake remove --version N. Run wake preview --json to list the current version.");
200
+ if (removing || preparingRemoval) {
201
+ if (o.url || o.preset || Object.keys(o).some(key => /^(?:key|hmac-secret|access-client)/.test(key))) fail("removal_inputs", "Omit draft settings and secrets when removing a wake. Run wake preview --json first.");
202
+ if (!/^(0|[1-9][0-9]*)$/.test(String(o.version ?? "")) || !Number.isSafeInteger(Number(o.version))) fail("version", "Pass --version N selected from wake preview --agent NAME --json. Removal never selects a version automatically.");
203
+ }
150
204
  if (sub === "prepare") {
151
205
  if (!o["reference-file"]) fail("reference_file", "Pass a new --reference-file FILE. The reference is saved mode 600 and never printed.");
152
206
  try { fs.lstatSync(o["reference-file"]); fail("reference_file", "Choose a new --reference-file path; existing files are never replaced."); }
153
207
  catch (error) { if (error.result) throw error; if (error.code !== "ENOENT") fail("reference_file", "Cannot inspect --reference-file; check its path and permissions."); }
154
208
  }
155
209
  let reference;
156
- if (sub === "set" && (o["reference-file"] || o["reference-env"])) {
210
+ if ((sub === "set" || removing) && (o["reference-file"] || o["reference-env"])) {
157
211
  if (o.url || o.preset || o["key-file"] || o["key-env"] || o["key-stdin"] || o["hmac-secret-file"] || o["hmac-secret-env"]) fail("reference_inputs", "Use a reference without draft overrides; it saves exactly the uploaded settings.");
158
212
  const next = "Run wake prepare --agent NAME with the draft inputs and a new --reference-file FILE, then use that protected reference with wake set. Existing files are never replaced.";
159
213
  try { reference = secret(o, "reference", ""); }
@@ -167,6 +221,22 @@ export async function wake(sub, o = {}) {
167
221
  const current = await request(origin, path, { token });
168
222
  if (!Number.isSafeInteger(current.version)) fail("invalid_response", "The service did not return a valid wake version. Saved settings were not changed.");
169
223
  if (sub === "preview") return { ok: true, code: "preview", next: "Test an explicit draft before saving.", version: current.version, preset: current.preset, configured: Boolean(current.url), key_header: ["hermes", "openbot"].includes(current.preset) ? "" : ["grok-bot", "claude-code", "openclaw-wake", "openclaw-agent"].includes(current.preset) ? "Authorization" : current.keyHeader || "Authorization", url: previewUrl(current.url) };
224
+ if (removing || preparingRemoval) {
225
+ const version = Number(o.version);
226
+ if (version !== current.version) fail("conflict", "Wake settings changed. Run wake preview --agent NAME --json, review them, then repeat removal with its explicit --version.");
227
+ const available = await capabilities({ "control-url": origin });
228
+ if (!available.wake_removal || (preparingRemoval && !available.secret_reference)) fail("unsupported", "This service does not support this removal operation. Use the agent's Wake settings on the website.");
229
+ if (preparingRemoval) {
230
+ const prepared = await request(origin, `${path}/reference`, { token, method: "POST", body: { version, clear: true } });
231
+ if (!/^a2awr_[a-f0-9]{64}$/.test(prepared.reference || "")) fail("invalid_response", "The service did not return a valid removal reference. No wake was removed.");
232
+ try { fs.writeFileSync(o["reference-file"], prepared.reference + "\n", { mode: 0o600, flag: "wx" }); }
233
+ catch { fail("reference_file", "Cannot create --reference-file. Choose a new path in a private directory; existing files are never replaced."); }
234
+ return { ok: true, code: "prepared", version, next: "Within ten minutes, use MCP set_wake with clear: true and secretReference, or wake remove --version N --reference-file FILE. The reference is single-use and never printed." };
235
+ }
236
+ const result = await request(origin, path, { token, method: "DELETE", body: { version, ...(reference ? { secretReference: reference } : {}) } });
237
+ const ok = result.ok === true && (result.removed === true || result.already_removed === true);
238
+ return { ok, code: ok ? result.already_removed === true ? "already_removed" : "removed" : "failed", removed: result.removed === true, already_removed: result.already_removed === true, version: result.version, next: ok ? "Use polling to read messages." : "Read wake preview --json before retrying removal with an explicit --version." };
239
+ }
170
240
  if (reference) {
171
241
  const result = await request(origin, path, { token, method: "PUT", body: { version: current.version, secretReference: reference } });
172
242
  return { ok: result.ok === true && result.saved === true, code: result.saved === true ? "saved" : "failed", saved: result.saved === true, next: result.saved === true ? "Check the receiver for the test wake." : "The reference is spent. Check saved settings before preparing another draft." };
@@ -212,7 +282,7 @@ export async function output(run, o = {}) {
212
282
  if (!result.ok) process.exitCode = 1;
213
283
  } catch (error) {
214
284
  const result = error.result || { ok: false, code: "unavailable", next: "Hosted setup did not finish. Check hosted list --json before retrying." };
215
- console.log(o.json ? JSON.stringify(result) : `${result.code}: ${result.next}`);
285
+ console.log(o.json ? JSON.stringify(result) : `${result.code}: ${result.next}${result.message ? `\nmessage: ${result.message}` : ""}`);
216
286
  process.exitCode = 1;
217
287
  }
218
288
  }
@@ -1,11 +1,71 @@
1
1
  import crypto from "node:crypto";
2
2
  import path from "node:path";
3
3
  import os from "node:os";
4
+ import { domainToASCII } from "node:url";
5
+ import { isIP } from "node:net";
4
6
  import { CliError } from "./a2a.mjs";
5
7
  import { controlOrigin } from "./account.mjs";
6
8
  import * as C from "./config.mjs";
7
9
  import { readSigningSecret, validateSecretSource, writeTokenFile, startConnector, connectorCommand } from "./local-link.mjs";
8
10
 
11
+ // Keep JSON diagnostics local: never serialize arbitrary service fields or errors.
12
+ function resultShape(ok, code, next, details = {}) {
13
+ return { ok, code, reason: null, field: null, phase: null, status: null, hostname: null, next, ...details };
14
+ }
15
+ function linkError(code, next, details = {}) {
16
+ const error = new CliError(next);
17
+ error.result = resultShape(false, code, next, details);
18
+ return error;
19
+ }
20
+ const presetNext = "Pass --preset ID selected from hosted capabilities --json before requesting approval.";
21
+
22
+ export function normalizeHostname(value) {
23
+ const invalid = (reason, next) => { throw linkError("invalid_hostname", next, { field: "hostname", reason, phase: "validation" }); };
24
+ if (typeof value !== "string" || !value.trim()) invalid("required", "Pass --hostname with a full DNS hostname on your Cloudflare domain.");
25
+ let hostname = value.trim();
26
+ if (/[\s\\\x00-\x1f\x7f]/.test(hostname)) invalid("characters", "--hostname must not contain whitespace, control characters or backslashes.");
27
+ if (hostname.includes("://")) {
28
+ if (!/^https:\/\//i.test(hostname)) invalid("scheme", "--hostname accepts a DNS hostname or an HTTPS URL.");
29
+ // Inspect authority before URL normalization can remove a default port or path.
30
+ const authority = hostname.slice(8).split(/[/?#]/, 1)[0];
31
+ if (authority.includes("@")) invalid("credentials", "--hostname must not contain URL credentials.");
32
+ if (authority.includes(":")) invalid("port", "--hostname must not contain a port.");
33
+ if (/[?#]/.test(hostname)) invalid("query_fragment", "--hostname must not contain a query or fragment.");
34
+ const suffix = hostname.slice(8 + authority.length);
35
+ if (suffix && suffix !== "/") invalid("path", "--hostname HTTPS URLs must have no path beyond /.");
36
+ hostname = authority;
37
+ } else {
38
+ if (hostname.includes("@")) invalid("credentials", "--hostname must not contain URL credentials.");
39
+ if (hostname.includes(":")) invalid("port", "--hostname must not contain a scheme or port; use an HTTPS URL or a DNS hostname.");
40
+ if (/[/?#]/.test(hostname)) invalid("path", "--hostname must not contain a path, query or fragment.");
41
+ }
42
+ // IDNA also normalizes Unicode dot separators. Remove exactly one root dot.
43
+ hostname = domainToASCII(hostname).toLowerCase().replace(/\.$/, "");
44
+ if (isIP(hostname) || /^\d+(?:\.\d+)*$/.test(hostname)) invalid("ip_address", "--hostname must be a DNS hostname, not an IP address.");
45
+ if (!hostname || hostname.length > 253 || !hostname.split(".").every(label => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)))
46
+ invalid("dns_labels", "--hostname needs DNS labels of 1–63 letters, digits or hyphens, with no empty labels or leading/trailing hyphens (253 characters maximum).");
47
+ if (!hostname.includes(".") || !/^(?:[a-z]{2,63}|xn--[a-z0-9-]{1,59})$/.test(hostname.split(".").at(-1)))
48
+ invalid("domain", "--hostname must be a full DNS hostname with a valid domain suffix.");
49
+ return hostname;
50
+ }
51
+
52
+ export function linkUsage() {
53
+ throw linkError("usage", "Use hosted link create --agent NAME --hostname HOST --origin URL --path PATH --preset ID [--json], or hosted link finish --agent NAME --preset ID [--json]; choose exactly one protected --secret-file FILE, --secret-env VAR, or --secret-from hermes. List choices with hosted list --json and hosted capabilities --json.", { phase: "validation" });
54
+ }
55
+
56
+ export async function linkOutput(run, o = {}) {
57
+ try {
58
+ const result = await run();
59
+ if (o.json) console.log(JSON.stringify(resultShape(true, result.saved ? "saved" : "connector_saved",
60
+ result.saved ? "The connector connected; the wake test passed and settings were saved." : "Start cloudflared with the saved token file, then run hosted link finish with --preset ID for fresh approval.",
61
+ { phase: result.saved ? "wake-test" : "connector", hostname: result.hostname, ...result })));
62
+ } catch (error) {
63
+ if (!o.json) throw error;
64
+ console.log(JSON.stringify(error.result || resultShape(false, "local_failure", "Hosted link setup did not finish. Check the local secret source, token-file permissions and cloudflared, then request fresh approval.")));
65
+ process.exitCode = 1;
66
+ }
67
+ }
68
+
9
69
  const sleepDefault = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
10
70
  const ERRORS = {
11
71
  expired_token: "The link request expired. Start a new one with hosted link create.",
@@ -70,7 +130,7 @@ const VALIDATION_MESSAGES = new Set([
70
130
  "Use an http or https origin with no path or credentials.",
71
131
  "Use a path beginning with /, with no query or fragment.",
72
132
  ]);
73
- const DOMAIN = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
133
+ const DOMAIN = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9-]{1,59})$/;
74
134
  function validationDetail(body, hostname) {
75
135
  if (typeof body?.message !== "string") return "";
76
136
  if (VALIDATION_MESSAGES.has(body.message)) return body.message.replace(/ and try again\.$/, ".");
@@ -105,12 +165,17 @@ function failure(status, body, { command, hostname }, phase = "requests") {
105
165
  : `The link request expired. Start a new one with hosted link ${command}.`;
106
166
  if (code === "invalid_request" && phase === "token") guidance = `The approval polling request was refused. Run hosted link ${command} again for fresh approval.`;
107
167
  const message = detail || guidance.replace(/run finish/gi, (match) => `${match.startsWith("R") ? "Run" : "run"} ${command}`);
108
- return new CliError(`link ${command} failed${hostname ? ` for ${hostname}` : ""} (${code}); ${message}`);
168
+ const reason = ["hostname_format", "origin_format", "path_format", "hostname_invalid", "preset_invalid", "origin_invalid", "path_invalid", "apex_hostname", "outside_zone", "existing_dns", "existing_tunnel"].includes(body?.reason) ? body.reason : known(body?.reason) ? body.reason : null;
169
+ const field = ["hostname", "origin", "path", "preset"].includes(body?.field) ? body.field : null;
170
+ const provisionPhase = ["cleanup", "hostname", "zone", "existing_dns", "existing_tunnel"].includes(body?.phase) ? body.phase : null;
171
+ return linkError(code.startsWith("HTTP ") ? `http_${status}` : code,
172
+ `link ${command} failed${hostname ? ` for ${hostname}` : ""} (${code}); ${message}`,
173
+ { reason, field, phase: code === "provision_failed" && provisionPhase ? provisionPhase : phase, status: code === "provision_failed" && Number.isInteger(body?.status) && body.status >= 100 && body.status <= 599 ? body.status : status, hostname: hostname || null });
109
174
  }
110
175
 
111
176
  export function savedLinkSession(origin, session = C.controlSession(), command = "finish") {
112
177
  if (controlOrigin(session.origin) !== origin || !session.token)
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`);
178
+ throw linkError("login_required", `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`, { phase: "validation" });
114
179
  return session.token;
115
180
  }
116
181
 
@@ -118,18 +183,16 @@ const CREATE_FIELDS = ["hostname", "origin", "path", "preset"];
118
183
 
119
184
  function createPlan(o) {
120
185
  for (const key of CREATE_FIELDS) {
121
- if (typeof o[key] !== "string" || !o[key]) throw new CliError(`hosted link create requires --${key}`);
186
+ if (typeof o[key] !== "string" || !o[key]) throw linkError("required", `hosted link create requires --${key}${key === "preset" ? "; " + presetNext : ""}`, { field: key, reason: "required", phase: "validation" });
122
187
  }
123
- const hostname = o.hostname.trim().toLowerCase();
124
- if (!/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(hostname))
125
- throw new CliError("--hostname must be a DNS hostname on your Cloudflare domain");
188
+ const hostname = normalizeHostname(o.hostname);
126
189
  let origin;
127
- try { origin = new URL(o.origin); } catch { throw new CliError("--origin must be an http or https origin"); }
190
+ try { origin = new URL(o.origin); } catch { throw linkError("invalid_origin", "--origin must be an http or https origin", { field: "origin", phase: "validation" }); }
128
191
  if (!/^https?:$/.test(origin.protocol) || origin.username || origin.password || origin.pathname !== "/" || origin.search || origin.hash)
129
- throw new CliError("--origin must be an http or https origin with no path or credentials");
192
+ throw linkError("invalid_origin", "--origin must be an http or https origin with no path or credentials", { field: "origin", phase: "validation" });
130
193
  if (!o.path.startsWith("/") || o.path.startsWith("//") || /[\s?#\\\x00-\x1f\x7f]/.test(o.path) || o.path.length > 1000)
131
- throw new CliError("--path must begin with / and have no query, fragment or whitespace");
132
- if (!/^[a-z][a-z0-9-]{0,63}$/.test(o.preset)) throw new CliError("--preset must be a wake preset id");
194
+ throw linkError("invalid_path", "--path must begin with / and have no query, fragment or whitespace", { field: "path", phase: "validation" });
195
+ if (!/^[a-z][a-z0-9-]{0,63}$/.test(o.preset)) throw linkError("preset", presetNext, { field: "preset", reason: o.preset === undefined || o.preset === "" ? "required" : "preset_invalid", phase: "validation" });
133
196
  return { hostname, origin: origin.origin, path: o.path, preset: o.preset };
134
197
  }
135
198
 
@@ -138,12 +201,12 @@ export function createLink(o, deps) {
138
201
  // Catch a wrong local route before asking the owner to create any resources.
139
202
  validateSecretSource(o);
140
203
  if (o["secret-from"] === "hermes" && (create.preset !== "hermes" || !/^\/webhooks\/[A-Za-z0-9_-]+$/.test(create.path) || (o.route && create.path !== `/webhooks/${o.route}`)))
141
- throw new CliError("Hermes route does not match the create path and preset");
204
+ throw linkError("route_mismatch", "Hermes route does not match the create path and preset", { field: "path", phase: "validation" });
142
205
  return setupLink(o, deps, create);
143
206
  }
144
207
 
145
208
  export function finishLink(o, deps) {
146
- if (CREATE_FIELDS.filter((key) => key !== "preset").some((key) => o[key] !== undefined)) throw new CliError("creation options require hosted link create; finish only reuses an existing link");
209
+ if (CREATE_FIELDS.filter((key) => key !== "preset").some((key) => o[key] !== undefined)) throw linkError("usage", "creation options require hosted link create; finish only reuses an existing link", { phase: "validation" });
147
210
  return setupLink(o, deps);
148
211
  }
149
212
 
@@ -156,10 +219,10 @@ async function setupLink(o, {
156
219
  validateSecretSource(o);
157
220
  const context = { command: create ? "create" : "finish", hostname: create?.hostname };
158
221
  const agent = o.agent;
159
- if (typeof agent !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}$/.test(agent)) throw new CliError("--agent must be an agent name (letters, digits, underscores or hyphens)");
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");
222
+ if (typeof agent !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}$/.test(agent)) throw linkError("agent", "--agent must be an agent name (letters, digits, underscores or hyphens); select it with hosted list --json", { field: "agent", phase: "validation" });
223
+ if (typeof o.preset !== "string" || !/^[a-z][a-z0-9-]{0,63}$/.test(o.preset)) throw linkError("preset", presetNext, { field: "preset", reason: o.preset === undefined || o.preset === "" ? "required" : "preset_invalid", phase: "validation" });
161
224
  const origin = controlOrigin(o["control-url"]);
162
- if (!origin) throw new CliError("--control-url must be an https origin with no path");
225
+ if (!origin) throw linkError("control_url", "--control-url must be an https origin with no path", { field: "control-url", phase: "validation" });
163
226
  const session = sessionToken(origin, undefined, context.command);
164
227
  const request = async (endpoint, body, grant, timeout = 15000) => {
165
228
  let response;
@@ -174,7 +237,7 @@ async function setupLink(o, {
174
237
  const parsed = JSON.parse(text);
175
238
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error();
176
239
  return { response, body: parsed };
177
- } catch { throw new CliError(`link ${context.command} request failed; check the connection and run ${context.command} again for fresh approval`); }
240
+ } catch { throw linkError("unavailable", `link ${context.command} request failed; check the connection and run ${context.command} again for fresh approval`, { phase: endpoint, hostname: context.hostname || null }); }
178
241
  };
179
242
  const verifier = crypto.randomBytes(32).toString("base64url");
180
243
  const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
@@ -182,9 +245,9 @@ async function setupLink(o, {
182
245
  if (started.response.status !== 201) throw failure(started.response.status, started.body, context);
183
246
  const setup = started.body;
184
247
  let approval;
185
- try { approval = new URL(setup.approve_url); } catch { throw new CliError("invalid approval response"); }
248
+ try { approval = new URL(setup.approve_url); } catch { throw linkError("invalid_response", "invalid approval response", { phase: "requests" }); }
186
249
  if (approval.origin !== origin || approval.username || approval.password || typeof setup.request_id !== "string" || !setup.request_id || typeof setup.user_code !== "string" || !/^[A-Za-z0-9-]{4,32}$/.test(setup.user_code))
187
- throw new CliError("invalid approval response");
250
+ throw linkError("invalid_response", "invalid approval response", { phase: "requests" });
188
251
  write(`${setup.approve_url}\n${setup.user_code}`);
189
252
  const deadline = now() + Math.min(600, Math.max(1, Number(setup.expires_in) || 600)) * 1000;
190
253
  let interval = Math.min(60, Math.max(5, Number(setup.interval) || 5)), authorized;
@@ -200,21 +263,22 @@ async function setupLink(o, {
200
263
  if (!authorized) throw failure(410, { error: "expired_token" }, context, "token");
201
264
  const { grant, link } = authorized;
202
265
  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")
203
- throw new CliError("invalid approved link response");
266
+ throw linkError("invalid_response", "invalid approved link response", { phase: "token" });
204
267
  if (create && CREATE_FIELDS.some((key) => link[key] !== create[key]))
205
- throw new CliError("approved link differs from the requested creation plan; no connector or signing secret was transferred");
206
- const preset = o.preset ?? link.preset;
207
- if (!/^[a-z][a-z0-9-]{0,63}$/.test(preset)) throw new CliError("approved link has no valid preset; rerun finish with --preset <id> before requesting fresh approval");
268
+ throw linkError("plan_mismatch", "approved link differs from the requested creation plan; no connector or signing secret was transferred", { phase: "token", hostname: context.hostname || null });
269
+ const hostname = normalizeHostname(link.hostname);
270
+ context.hostname = hostname;
271
+ const preset = o.preset;
208
272
  // Resolve and validate the source before spending the one-time connector delivery.
209
273
  const secret = readSecret(o, { ...link, preset });
210
274
  const connector = await request("connector", {}, grant);
211
275
  if (!connector.response.ok) throw failure(connector.response.status, connector.body, context, "connector");
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");
276
+ if (typeof connector.body.connector_token !== "string" || !connector.body.connector_token.trim() || /\s/.test(connector.body.connector_token)) throw linkError("invalid_response", "invalid connector response; run finish again for fresh approval", { phase: "connector" });
213
277
  const tokenFile = path.resolve(o["token-file"] || path.join(os.homedir(), ".a2a-exposed", "links", `${agent}.token`));
214
278
  saveToken(tokenFile, connector.body.connector_token);
215
279
  if (o["no-run"]) {
216
280
  write(connectorCommand(tokenFile));
217
- return { saved: false, connectorStarted: false };
281
+ return { saved: false, connectorStarted: false, hostname };
218
282
  }
219
283
  await runConnector(tokenFile);
220
284
  let connected = false;
@@ -226,21 +290,21 @@ async function setupLink(o, {
226
290
  if (status.body.connector === "connected" && Number.isInteger(status.body.connections) && status.body.connections > 0) { connected = true; break; }
227
291
  await sleep(Math.min(3000, Math.max(0, connectDeadline - now())));
228
292
  }
229
- if (!connected) throw new CliError("connector did not report connected within 60 seconds; check cloudflared and run finish again for fresh approval");
293
+ if (!connected) throw linkError("connector_timeout", "connector did not report connected within 60 seconds; check cloudflared and run finish again for fresh approval", { phase: "status", hostname });
230
294
  const tested = await request("wake-test", { signing_secret: secret, preset }, grant);
231
295
  // These 409 codes come from saveWakeSettings after a successful wake test.
232
296
  const saveRejected = tested.response.status === 409 && ["url", "preset", "header", "key", "unavailable", "unreadable", "conflict", "failed"].includes(tested.body.error);
233
297
  if (!tested.response.ok && !saveRejected) throw failure(tested.response.status, tested.body, context, "grant");
234
298
  if (saveRejected || (tested.body.result === "ok" && tested.body.saved !== true)) {
235
299
  write("wake_result=not-saved");
236
- throw new CliError("The test wake was delivered, but the settings were not saved. Save them in Wake settings on the website, then test again. The saved connector token remains on disk.");
300
+ throw linkError("not_saved", "The test wake was delivered, but the settings were not saved. Save them in Wake settings on the website, then test again. The saved connector token remains on disk.", { phase: "wake-test", status: tested.response.status, hostname });
237
301
  }
238
302
  if (tested.body.result !== "ok") {
239
303
  const { result, message } = wakeFailure(tested.body.result);
240
304
  write(`wake_result=${result}`);
241
- throw new CliError(message);
305
+ throw linkError("wake_failed", message, { reason: result, phase: "wake-test", status: tested.response.status, hostname });
242
306
  }
243
307
  write("wake_result=ok");
244
308
  write("Connector connected. Wake test passed and settings saved.");
245
- return { saved: true, connectorStarted: true };
309
+ return { saved: true, connectorStarted: true, hostname };
246
310
  }
package/lib/main.mjs CHANGED
@@ -13,7 +13,7 @@ import * as hosted from "./hosted.mjs";
13
13
  import * as upd from "./update.mjs";
14
14
  import * as fb from "./feedback.mjs";
15
15
  import * as un from "./uninstall.mjs";
16
- import { finishLink, createLink } from "./link-finish.mjs";
16
+ import { finishLink, createLink, linkOutput, linkUsage } from "./link-finish.mjs";
17
17
 
18
18
  const VERSION = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
19
19
 
@@ -31,7 +31,10 @@ Hosted setup:
31
31
  login --machine NAME --scopes SCOPES [--control-url URL] --json
32
32
  approve the explicit scopes using the printed website link once;
33
33
  save the scoped grant locally (mode 600), never print it
34
- hosted list --json list hosted agents before choosing --name or --agent
34
+ hosted names --name NAME --json
35
+ check one name anonymously before device approval; never lists other agents
36
+ hosted capacity --json read account room with agents:read (or agents:create); reserves nothing
37
+ hosted list --json list hosted agents with agents:read or agents:create before choosing an agent
35
38
  hosted create --name NAME --json
36
39
  create and save the owner credential in this config directory;
37
40
  use a separate --config-dir for each agent
@@ -45,7 +48,13 @@ Hosted setup:
45
48
  wake set --agent NAME --reference-file FILE --json
46
49
  test and save exactly that reference's draft; --reference-env VAR also works
47
50
  MCP set_wake accepts secretReference on services listing that capability
48
- Hosted wake removal uses the agent's Wake settings on the website.
51
+ Hosted reference capacity failures report too_many_references and a retry wait; never auto-retry.
52
+ wake remove --agent NAME --version N --json
53
+ read wake preview --json first; remove only the version explicitly selected
54
+ wake unset is an alias; credentials remain in protected storage
55
+ wake prepare --agent NAME --clear --version N --reference-file FILE --json
56
+ prepare a removal reference for MCP set_wake clear: true, secretReference;
57
+ without that reference MCP keeps browser confirmation
49
58
  Listings and previews show result fields; --json provides structured output.
50
59
 
51
60
  Setup (needs Node 22.18+, e.g. \`mise exec node@22 -- npx -y a2a-exposed@latest ...\`, and a Cloudflare login:
@@ -223,7 +232,8 @@ Inbox (owner side)
223
232
  history and continue with send. Then pending pairing
224
233
  requests (tell your human; the dashboard URL when MANAGE_URL is set;
225
234
  --json: rows on stdout, requests on stderr). Follows every page; past
226
- 10000 pages it errors instead of a short list
235
+ 10000 pages it errors instead of a short list. Reading marks returned replies
236
+ seen and clears matching pending wakes; task state is unchanged
227
237
  show <taskId> full task JSON
228
238
  working <taskId> mark task working
229
239
  reply <taskId> (--text T | --stdin) [--state completed|input-required|rejected|failed|working]
@@ -379,7 +389,7 @@ const deployOpts = {
379
389
  const SPEC = {
380
390
  hosted: { name: S, json: B, agent: S, hostname: S, origin: S, path: S, preset: S, "control-url": S, "secret-file": S, "secret-env": S, "secret-from": S, "hermes-home": S, route: S, "token-file": S, "no-run": B },
381
391
  init: { ...deployOpts, ...tunnelOpts, tunnel: B }, deploy: deployOpts,
382
- wake: { ...deployOpts, json: B, url: S, "reference-file": S, "reference-env": S, agent: S, "control-url": S, "key-header": S, "key-file": S, "key-env": S, "key-stdin": B, "hmac-secret-file": S, "hmac-secret-env": S, "access-client-id-file": S, "access-client-id-env": S, "access-client-secret-file": S, "access-client-secret-env": S }, tunnel: tunnelOpts,
392
+ wake: { ...deployOpts, json: B, url: S, "reference-file": S, "reference-env": S, clear: B, version: S, agent: S, "control-url": S, "key-header": S, "key-file": S, "key-env": S, "key-stdin": B, "hmac-secret-file": S, "hmac-secret-env": S, "access-client-id-file": S, "access-client-id-env": S, "access-client-secret-file": S, "access-client-secret-env": S }, tunnel: tunnelOpts,
383
393
  inbox: { context: S, from: S, all: B, json: B },
384
394
  reply: { text: S, stdin: B, state: { type: "string", default: "completed" }, artifact: B, "artifact-name": { type: "string", default: "response" }, force: B },
385
395
  history: { from: S, n: { type: "string", short: "n", default: "50" }, json: B },
@@ -437,6 +447,7 @@ export async function main(argv) {
437
447
  try {
438
448
  parsed = parseArgs({ args: rest, options: SPEC[name] || {}, allowPositionals: true, strict: true });
439
449
  } catch (e) {
450
+ if (name === "hosted" && rest.includes("link") && rest.some(value => ["create", "finish"].includes(value)) && rest.includes("--json")) return linkOutput(linkUsage, { json: true });
440
451
  if (e && e.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION") {
441
452
  const flag = (String(e.message).match(/'([^']+)'/) || [])[1] || "that flag";
442
453
  if (flag === "-y") throw new CliError("unknown flag -y. To skip a prompt, pass --yes.");
@@ -502,9 +513,9 @@ export async function main(argv) {
502
513
  throw new CliError(`unknown peer action ${sub} (announce|forward)`);
503
514
  }
504
515
  case "hosted": {
505
- if (p.length === 1 && ["capabilities", "list", "create"].includes(p[0])) return hosted.output(() => hosted[p[0]](o), o);
506
- if (p.length !== 2 || p[0] !== "link" || !["finish", "create"].includes(p[1])) throw new CliError("usage: hosted link create --agent NAME --hostname HOST --origin URL --path PATH --preset ID (--secret-file FILE | --secret-env VAR | --secret-from hermes); or hosted link finish --agent NAME (--secret-file FILE | --secret-env VAR | --secret-from hermes)");
507
- return (p[1] === "create" ? createLink : finishLink)({ ...o, "control-url": o["control-url"] || C.get("CONTROL_URL") });
516
+ if (p.length === 1 && ["capabilities", "list", "create", "names", "capacity"].includes(p[0])) return hosted.output(() => hosted[p[0]](o), o);
517
+ if (p.length !== 2 || p[0] !== "link" || !["finish", "create"].includes(p[1])) return linkOutput(linkUsage, o);
518
+ return linkOutput(() => (p[1] === "create" ? createLink : finishLink)({ ...o, "control-url": o["control-url"] || C.get("CONTROL_URL") }), o);
508
519
  }
509
520
  case "signup":
510
521
  case "login": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.8.32",
3
+ "version": "0.8.34",
4
4
  "description": "Give any AI agent a public A2A (Agent2Agent) endpoint: a Cloudflare Worker, a wake webhook, or scheduled checks",
5
5
  "type": "module",
6
6
  "bin": {
@@ -602,12 +602,12 @@ const agentCard = (ctx: TenantContext): Promise<Json> => (proxyMode(ctx) ? facad
602
602
 
603
603
  /** The same agent as an A2A 0.3 AgentCard (served at the legacy /.well-known/agent.json for 0.2/0.3 clients): top-level
604
604
  * url + preferredTransport + protocolVersion, OpenAPI-style securitySchemes and `security`. 0.3 has no device-code
605
- * flow, so the pairing scheme only points to the RFC 8414 metadata (oauth2MetadataUrl) and is not a requirement. */
605
+ * flow, so only bearer authentication is advertised; pairing discovery stays on the 1.0 card. */
606
606
  function agentCard03(ctx: TenantContext, c: Json): Json {
607
607
  const base = ctx.PUBLIC_URL.replace(/\/$/, "");
608
- const schemes: Json = { bearer: { type: "http", scheme: "bearer", description: c.securitySchemes.bearer.httpAuthSecurityScheme.description } };
609
- if (pairingOn(ctx)) schemes.pairing = { type: "oauth2", description: c.securitySchemes.pairing.oauth2SecurityScheme.description,
610
- flows: {}, oauth2MetadataUrl: oauthUrls(ctx).metadata };
608
+ const schemes: Json = { bearer: { type: "http", scheme: "bearer", description: pairingOn(ctx)
609
+ ? `Per-peer bearer token: from device-flow pairing described at ${base}/.well-known/agent-card.json (A2A 1.0), or issued by the agent's operator`
610
+ : c.securitySchemes.bearer.httpAuthSecurityScheme.description } };
611
611
  const card: Json = {
612
612
  protocolVersion: "0.3.0", name: c.name, description: c.description, url: base + "/", preferredTransport: "JSONRPC",
613
613
  additionalInterfaces: [{ url: base + "/", transport: "JSONRPC" }], version: c.version,
@@ -2663,7 +2663,7 @@ async function devicePage(req: Request, ctx: TenantContext, ectx: ExecutionConte
2663
2663
  model.askCode = true;
2664
2664
  model.codeValue = raw || "";
2665
2665
  if (raw && !notice) {
2666
- let text = "That is not a valid code (it looks like WDJB-4827).";
2666
+ let text = "Enter four consonants, a hyphen, and four digits.";
2667
2667
  if (code && !r) { lookupStatus = 404; text = PAIRING_NOT_FOUND; }
2668
2668
  else if (r && r.status !== "pending") text = `That request was already ${r.status === "denied" ? "denied" : "approved"}.`;
2669
2669
  else if (code && r) { lookupStatus = 404; text = PAIRING_EXPIRED; }
@@ -2683,6 +2683,11 @@ async function devicePage(req: Request, ctx: TenantContext, ectx: ExecutionConte
2683
2683
  await show(form.user_code || null, { kind: "error", text: "This form expired or came from another site. Reload the page and try again." });
2684
2684
  return send(403);
2685
2685
  }
2686
+ // Code entry is a lookup only; approval and denial remain separate CSRF-protected submissions.
2687
+ if (form.decision === "lookup") {
2688
+ await show(form.user_code || null);
2689
+ return send(lookupStatus);
2690
+ }
2686
2691
  const code = P.normUserCode(form.user_code);
2687
2692
  if (!code) { await show(form.user_code || null); return send(400); }
2688
2693
  // Deny needs no password: it grants nothing, and the worst a holder of the code can do is cancel a pending request
package/worker/src/mcp.ts CHANGED
@@ -319,8 +319,8 @@ const str = (description: string, extra: Schema = {}): Schema => ({ type: "strin
319
319
  const ro = { readOnlyHint: true, destructiveHint: false, openWorldHint: false };
320
320
 
321
321
  export const TOOLS: { name: string; title: string; description: string; inputSchema: Schema; outputSchema?: Schema; annotations: Schema }[] = [
322
- { name: "inbox", title: "Inbox", annotations: ro,
323
- description: `Open tasks other agents sent to this A2A endpoint (state submitted or working), oldest first, one page at a time, plus a reply to a message you sent (kind "outbound") until this inbox lists it, plus pending pairing requests. When the result says more tasks are waiting, call inbox again with the cursor before you stop. Handle each inbound task, then answer it with reply. A reply to a message you sent: read it with history, and continue that conversation with send. ${RULES}`,
322
+ { name: "inbox", title: "Inbox", annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
323
+ description: `Open tasks other agents sent to this A2A endpoint (state submitted or working), oldest first, one page at a time, plus a reply to a message you sent (kind "outbound") until this inbox lists it, plus pending pairing requests. Reading this page marks returned replies seen and clears matching pending wakes; task state is unchanged. When the result says more tasks are waiting, call inbox again with the cursor before you stop. Handle each inbound task, then answer it with reply. A reply to a message you sent: read it with history, and continue that conversation with send. ${RULES}`,
324
324
  inputSchema: { type: "object", properties: {
325
325
  contextId: str("Only this conversation"),
326
326
  from: str("Only this peer (the wake's from). The same context id can be a different peer's conversation"),
@@ -224,7 +224,8 @@ export type PageModel = {
224
224
  };
225
225
 
226
226
  export function devicePage(m: PageModel, nonce: string): string {
227
- const notice = m.notice ? `<p class="n ${m.notice.kind}">${esc(m.notice.text)}</p>` : "";
227
+ const errorAttrs = m.notice?.kind === "error" ? ` aria-invalid="true" aria-describedby="device-error"` : "";
228
+ const notice = m.notice ? `<p${errorAttrs ? ` id="device-error" role="alert"` : ""} class="n ${m.notice.kind}">${esc(m.notice.text)}</p>` : "";
228
229
  if (m.settled) {
229
230
  const title = m.settled === "approved" ? "Approved" : "Denied";
230
231
  return pageShell(title, nonce, `${notice}<p>You can close this page.</p>`);
@@ -253,14 +254,14 @@ export function devicePage(m: PageModel, nonce: string): string {
253
254
  const passwordOn = m.passwordEnabled !== false;
254
255
  const form = r && passwordOn && m.passwordSet ? `<form method="post" action="/device">
255
256
  <input type="hidden" name="csrf" value="${esc(m.csrf)}"><input type="hidden" name="user_code" value="${esc(formatUserCode(r.userCode))}">
256
- <label for="pw">Approval password (needed to approve; deny works without it)</label><input id="pw" name="password" type="password" autocomplete="current-password" required maxlength="1024" autofocus>
257
+ <label for="pw">Approval password (needed to approve; deny works without it)</label><input id="pw" name="password" type="password"${errorAttrs} autocomplete="current-password" required maxlength="1024" autofocus>
257
258
  <div class="b"><button name="decision" value="approve" type="submit">Approve</button><button name="decision" value="deny" type="submit" class="d" formnovalidate>Deny</button></div>
258
259
  </form>` : r && passwordOn && !m.passwordSet ? noPassword + denyOnly : r ? denyOnly : "";
259
260
  const oidcForm = m.oidc && r ? `<form method="post" action="/device">
260
261
  <input type="hidden" name="csrf" value="${esc(m.csrf)}"><input type="hidden" name="user_code" value="${esc(formatUserCode(r.userCode))}">
261
262
  <div class="b"><button name="decision" value="oidc" type="submit">Approve with ${esc(m.oidc.label)}</button></div>
262
263
  </form>` : "";
263
- const ask = m.askCode ? `<p class="w">The connecting agent prints this code when it runs <code>${esc(`${m.cli} connect <url>`)}</code>. It looks like WDJB-4827.</p><form method="get" action="/device"><label for="uc">Code</label><input id="uc" name="user_code" value="${esc(m.codeValue || "")}" placeholder="WDJB-4827" autocomplete="off" autocapitalize="characters" required maxlength="16" autofocus><div class="b"><button type="submit">Continue</button></div></form>` : "";
264
+ const ask = m.askCode ? `<p class="w">The connecting agent prints this code when it runs <code>${esc(`${m.cli} connect <url>`)}</code>. Use four consonants, a hyphen, and four digits.</p><form method="post" action="/device"><input type="hidden" name="csrf" value="${esc(m.csrf)}"><input type="hidden" name="decision" value="lookup"><label for="uc">Code</label><input id="uc" name="user_code" value="${esc(m.codeValue || "")}" placeholder="XXXX-0000"${errorAttrs} spellcheck="false" autocomplete="off" autocapitalize="characters" required maxlength="16" autofocus><div class="b"><button type="submit">Continue</button></div></form>` : "";
264
265
  return pageShell(`Connect an agent to ${m.agentName}`, nonce, `${notice}${details}${form}${oidcForm}${ask}`);
265
266
  }
266
267
 
@@ -1,2 +1,2 @@
1
1
  /** Inbox build. Keep this equal to `version` in cli/package.json. */
2
- export const VERSION = "0.8.32";
2
+ export const VERSION = "0.8.34";
@@ -195,6 +195,8 @@ test("proxy mode: the served cards are the upstream's, rewritten to the public U
195
195
  const c03 = (await s.call("GET", "/.well-known/agent.json")).data;
196
196
  assert.equal(c03.url, BASE + "/");
197
197
  assert.equal(c03.name, "Jean");
198
+ assert.deepEqual(Object.keys(c03.securitySchemes), ["bearer"], "0.3 façade omits unsupported pairing flow");
199
+ assert.deepEqual(c03.security, [{ bearer: [] }]);
198
200
  assert.deepEqual(F.cardLeaks(c03, BASE, [TAILNET]), []);
199
201
  // the card fetch carried the façade's upstream credentials (Access service token + bearer)
200
202
  const cf = s.calls.find((c) => c.url.endsWith("/card-a.json"));
@@ -851,6 +851,11 @@ test("parseHttpResponse: status, headers, content-length and chunked bodies; mal
851
851
  });
852
852
 
853
853
  test("inbox tool clears wakes only for the page it returns, and a later call can fetch the rest", async (t) => {
854
+ const inbox = M.TOOLS.find(tool => tool.name === "inbox")!;
855
+ assert.equal(inbox.annotations.readOnlyHint, false, "inbox acknowledges reads");
856
+ assert.equal(inbox.annotations.destructiveHint, false, "acknowledgement preserves tasks");
857
+ assert.match(inbox.description, /marks returned replies seen and clears matching pending wakes/);
858
+ assert.equal(M.TOOLS.find(tool => tool.name === "history")!.annotations.readOnlyHint, true);
854
859
  const s = setup({ WAKE_DEBOUNCE_SECONDS: "3600" });
855
860
  t.after(s.restore);
856
861
  const a = await s.connect();
@@ -65,6 +65,66 @@ function setup(over: Record<string, string> = {}) {
65
65
  return { env, DB, wakes, call, owner, start, poll, decidePage, restore: () => { globalThis.fetch = origFetch; } };
66
66
  }
67
67
 
68
+ test("device code entry posts a CSRF-protected lookup without making a pairing decision", async (t) => {
69
+ const s = setup(); t.after(s.restore);
70
+ const d = (await s.start()).data;
71
+ const entry = await s.call("GET", "/device");
72
+ assert.match(entry.text, /<form method="post" action="\/device">/);
73
+ assert.doesNotMatch(entry.text, /method="get"|placeholder="WDJB-4827"|aria-invalid/);
74
+ assert.match(entry.text, /placeholder="XXXX-0000"/);
75
+ assert.match(entry.text, /spellcheck="false" autocomplete="off" autocapitalize="characters"/);
76
+ assert.match(entry.text, /name="decision" value="lookup"/);
77
+ const cookie = entry.headers.get("set-cookie")!.split(";")[0];
78
+ const csrf = /name="csrf" value="([^"]+)"/.exec(entry.text)![1];
79
+ const lookup = (code: string, overrides = {}) => s.call("POST", "/device", {
80
+ form: { csrf, decision: "lookup", user_code: code }, cookie, headers: { origin: BASE }, ...overrides,
81
+ });
82
+ const page = await lookup(d.user_code.toLowerCase());
83
+ assert.equal(page.status, 200, page.text);
84
+ assert.equal(page.headers.get("location"), null, "no redirect puts the code in a URL");
85
+ assert.match(page.text, /Barry Bot/);
86
+ assert.match(page.text, /name="decision" value="deny"/);
87
+ assert.doesNotMatch(page.text, /aria-invalid/);
88
+ assert.equal((await s.poll(d.device_code)).data.error, "authorization_pending");
89
+ assert.equal(s.DB.db.prepare("SELECT status FROM device_requests").get()!.status, "pending");
90
+ assert.equal((await lookup(d.user_code, { cookie: "" })).status, 403);
91
+ assert.equal((await lookup(d.user_code, { headers: { origin: "https://evil.example" } })).status, 403);
92
+ assert.equal((await lookup(d.user_code, { form: { decision: "lookup", user_code: d.user_code } })).status, 403);
93
+ const invalid = await lookup("<bad>");
94
+ assert.equal(invalid.status, 200);
95
+ assert.match(invalid.text, /value="&lt;bad&gt;"/);
96
+ assert.match(invalid.text, /<input id="uc"[^>]*aria-invalid="true" aria-describedby="device-error"/);
97
+ assert.match(invalid.text, /<p id="device-error" role="alert"[^>]*>Enter four consonants/);
98
+ assert.equal((await lookup("BCDF-2345")).status, 404);
99
+ advance(3601);
100
+ const expired = await lookup(d.user_code);
101
+ assert.equal(expired.status, 404);
102
+ assert.match(expired.text, /aria-describedby="device-error"/);
103
+ assert.match(expired.text, /expired/i);
104
+ });
105
+
106
+ test("POST code lookups keep the existing IP limit", async (t) => {
107
+ const s = setup(); t.after(s.restore);
108
+ const g = await s.call("GET", "/device");
109
+ const cookie = g.headers.get("set-cookie")!.split(";")[0];
110
+ const csrf = /name="csrf" value="([^"]+)"/.exec(g.text)![1];
111
+ const lookup = () => s.call("POST", "/device", { form: { csrf, decision: "lookup", user_code: "BCDF-2345" }, cookie, headers: { origin: BASE } });
112
+ for (let i = 0; i < P.LIMITS.lookupsPerIp; i++) assert.equal((await lookup()).status, 404);
113
+ const limited = await lookup();
114
+ assert.match(limited.text, /Too many code lookups/);
115
+ assert.match(limited.text, /<input id="uc"[^>]*aria-invalid="true" aria-describedby="device-error"/);
116
+ });
117
+
118
+ test("wrong approval passwords identify the password input and error text", async (t) => {
119
+ const s = setup(); t.after(s.restore);
120
+ assert.equal((await s.owner("PUT", "/owner/pairing/password", await passwordRecord())).status, 200);
121
+ const d = (await s.start()).data;
122
+ const page = await s.decidePage(d.user_code, "wrong password", "approve");
123
+ assert.equal(page.status, 403);
124
+ assert.match(page.text, /<input id="pw"[^>]*aria-invalid="true" aria-describedby="device-error"/);
125
+ assert.match(page.text, /<p id="device-error" role="alert"[^>]*>Wrong password/);
126
+ });
127
+
68
128
  test("HEAD answers like GET with an empty body, and a self-hosted card stays cached for 5 minutes", async (t) => {
69
129
  const s = setup();
70
130
  t.after(s.restore);
@@ -208,8 +268,8 @@ test("/.well-known/agent.json serves an A2A 0.3 card (url, preferredTransport, p
208
268
  assert.deepEqual([old.name, old.version], ["Test Inbox", "1.0.0"]);
209
269
  assert.deepEqual(old.capabilities, { streaming: false, pushNotifications: true, stateTransitionHistory: false });
210
270
  assert.deepEqual(old.securitySchemes.bearer, { type: "http", scheme: "bearer", description: old.securitySchemes.bearer.description });
211
- assert.equal(old.securitySchemes.pairing.type, "oauth2");
212
- assert.equal(old.securitySchemes.pairing.oauth2MetadataUrl, `${BASE}/.well-known/oauth-authorization-server`);
271
+ assert.deepEqual(Object.keys(old.securitySchemes), ["bearer"], "0.3 has no device-code OAuth flow");
272
+ assert.match(old.securitySchemes.bearer.description, /device-flow pairing described at https:\/\/agent\.example\.com\/\.well-known\/agent-card\.json/);
213
273
  assert.deepEqual(old.security, [{ bearer: [] }]);
214
274
  assert.deepEqual(old.skills, [{ id: "s1", name: "S1", description: "d", tags: [] }], "0.3 requires tags");
215
275
  assert.equal(old.supportedInterfaces, undefined, "no 1.0-only fields");
@@ -903,7 +963,7 @@ test("device page copy: code hint, expiry, token scope, settled result, A2A endp
903
963
  const base = { agentName: "this A2A endpoint", mode: "human" as const, cli: "a2a", csrf: "c".repeat(24), passwordSet: true };
904
964
  const bare = P.devicePage({ ...base, askCode: true }, "n");
905
965
  assert.match(bare, /The connecting agent prints this code when it runs <code>a2a connect &lt;url&gt;<\/code>/);
906
- assert.match(bare, /It looks like WDJB-4827/);
966
+ assert.match(bare, /four consonants, a hyphen, and four digits/);
907
967
  assert.match(bare, /<title>Connect an agent to this A2A endpoint<\/title>/);
908
968
  const req = { userCode: "WDJB4827", clientName: "Barry", clientId: "barry", agentCardUrl: "http://127.0.0.1/card", ip: "192.0.2.1", country: "", createdMs: now, expiresMs: now + 20_000 };
909
969
  const page = P.devicePage({ ...base, request: req }, "n");
@@ -934,7 +994,7 @@ test("device page copy: code hint, expiry, token scope, settled result, A2A endp
934
994
  const s = setup(); t.after(s.restore);
935
995
  const empty = await s.call("GET", "/device");
936
996
  assert.match(empty.text, /prints this code when it runs/);
937
- assert.match(empty.text, /WDJB-4827/);
997
+ assert.match(empty.text, /placeholder="XXXX-0000"/);
938
998
  const named = setup({ AGENT_NAME: "" }); t.after(named.restore);
939
999
  const title = await named.call("GET", "/device");
940
1000
  assert.match(title.text, /Connect an agent to this A2A endpoint/);