a2a-exposed 0.8.27 → 0.8.28

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/config.mjs CHANGED
@@ -54,6 +54,14 @@ export function get(key, dflt = "") {
54
54
  return f !== undefined && f !== "" ? f : dflt;
55
55
  }
56
56
 
57
+ /** Resolve a control credential with the origin from the same source.
58
+ * A partial environment override must never borrow saved credentials.
59
+ */
60
+ export function controlSession(env = process.env, saved = fileConfig()) {
61
+ const source = env.CONTROL_URL || env.CONTROL_TOKEN ? env : saved;
62
+ return { origin: source.CONTROL_URL || "", token: source.CONTROL_TOKEN || "" };
63
+ }
64
+
57
65
  function ensureDir() {
58
66
  fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
59
67
  }
package/lib/deploy.mjs CHANGED
@@ -124,6 +124,21 @@ function run(cmd, args, { cwd, env, capture = false, captureStderr = false, allo
124
124
  return r;
125
125
  }
126
126
 
127
+ /** Keep dependency installer output in a private local log. */
128
+ export function installDependencies(dir, { configDir = C.CONFIG_DIR, env = process.env } = {}) {
129
+ fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
130
+ const log = path.join(configDir, "npm-install.log");
131
+ const fd = fs.openSync(log, "w", 0o600);
132
+ fs.fchmodSync(fd, 0o600);
133
+ let result;
134
+ try {
135
+ result = spawnSync("npm", ["install", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, env, stdio: ["ignore", fd, fd] });
136
+ } finally {
137
+ fs.closeSync(fd);
138
+ }
139
+ if (result.error || result.status !== 0) die(`npm install failed; inspect the private log: ${log}`);
140
+ }
141
+
127
142
  export function cfBin(dir) {
128
143
  const local = path.join(dir, "node_modules", ".bin", "cf");
129
144
  return fs.existsSync(local) ? local : "cf";
@@ -561,7 +576,7 @@ export async function init(o) {
561
576
  step(`worker project -> ${dir}`);
562
577
  syncTemplate(dir);
563
578
  requireCfLogin(dir);
564
- if (!o["skip-install"]) run("npm", ["install", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir });
579
+ if (!o["skip-install"]) installDependencies(dir);
565
580
  const env = cfEnv();
566
581
 
567
582
  const dbName = C.get("A2A_D1_NAME") || C.get("A2A_WORKER_NAME");
@@ -588,6 +603,7 @@ export async function init(o) {
588
603
  const base = deployedBase();
589
604
  if (base) await verifyDeployedCard(base);
590
605
  console.log(base);
606
+ await reportApprovalReadiness(base, ownerToken);
591
607
  reportSwitch(switched);
592
608
  proxyNote(secrets);
593
609
  if (rotated) console.error(`owner token replaced. Update A2A_OWNER_TOKEN wherever this A2A endpoint is called, then run \`${CLI} peers sync\` so uploaded peers stay readable.`);
@@ -595,6 +611,21 @@ export async function init(o) {
595
611
  printStar();
596
612
  }
597
613
 
614
+ /** Read the deployed inbox, using its saved owner token rather than an environment override. */
615
+ async function reportApprovalReadiness(base, ownerToken) {
616
+ try {
617
+ if (!base) throw new Error();
618
+ const { status, data } = await httpJson(`${base}/owner/pairing`, { headers: { authorization: `Bearer ${ownerToken}` }, timeout: 3000 });
619
+ if (status !== 200 || typeof data?.passwordSet !== "boolean" || !["human", "agent", "off"].includes(data.mode)) throw new Error();
620
+ if (data.mode === "off" || (Array.isArray(data.approvalMethods) && !data.approvalMethods.includes("password"))) return;
621
+ console.error(data.passwordSet ? "Approval password: set."
622
+ : `Approval password: not set. To approve pairing on /device with a password, run: ${CLI} pair set-password --web`);
623
+ if (!data.passwordSet) console.error("Send the one-time setup link privately to your human. They set the password themselves; never open the page or ask for the password.");
624
+ } catch {
625
+ console.error(`Approval password: could not check. Run: ${CLI} pair list --json`);
626
+ }
627
+ }
628
+
598
629
  /** Proxy / expose mode reminder after a deploy (the upstream credentials are secrets: only their names are shown). */
599
630
  function proxyNote(secrets = {}) {
600
631
  const up = C.get("A2A_UPSTREAM_URL");
@@ -685,7 +716,7 @@ export async function deploy(o) {
685
716
  guardTunnelUrl();
686
717
  step(`worker project -> ${dir}${cfProfile() ? ` (cf profile ${cfProfile()})` : ""}`);
687
718
  syncTemplate(dir);
688
- if (!o["skip-install"]) run("npm", ["install", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir });
719
+ if (!o["skip-install"]) installDependencies(dir);
689
720
  const env = cfEnv();
690
721
  applyMigrations(dir, env);
691
722
  const secrets = { ...deploySecretsFromEnv(), ...upstreamSecrets, ...await eventSecretFromStdin(o) };
package/lib/feedback.mjs CHANGED
@@ -32,7 +32,7 @@ const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
32
32
  /** The repository that will receive the issue. The prefilled form is ISSUE_REPO. A hosted post uses the name that URL returns. */
33
33
  export function publicIssueWarning(repo = ISSUE_REPO) {
34
34
  const name = typeof repo === "string" && REPO_RE.test(repo) && repo.length <= 100 && !repo.includes("..") ? repo : ISSUE_REPO;
35
- return `This report is filed as a public GitHub issue on ${name}. Do not include confidential information: secrets, tokens, owner or pairing tokens, private URLs or hostnames, message contents, or personal data.`;
35
+ return `Posting this report creates a public GitHub issue on ${name}. Do not include confidential information: secrets, tokens, owner or pairing tokens, private URLs or hostnames, message contents, or personal data.`;
36
36
  }
37
37
 
38
38
  export const PUBLIC_ISSUE_WARNING = publicIssueWarning(ISSUE_REPO);
@@ -316,6 +316,7 @@ export async function runFeedback(input) {
316
316
  }),
317
317
  });
318
318
  if (!endpoint) return fallback();
319
+ await input.beforePost?.();
319
320
  let response;
320
321
  try {
321
322
  response = await call(endpoint, {
@@ -3,33 +3,110 @@ import path from "node:path";
3
3
  import os from "node:os";
4
4
  import { CliError } from "./a2a.mjs";
5
5
  import { controlOrigin } from "./account.mjs";
6
+ import * as C from "./config.mjs";
6
7
  import { readSigningSecret, validateSecretSource, writeTokenFile, startConnector, connectorCommand } from "./local-link.mjs";
7
8
 
8
9
  const sleepDefault = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
9
- const ERRORS = new Set(["access_denied", "expired_token", "link_not_ready", "invalid_grant", "already_delivered", "connector_not_connected", "not_found", "cloudflare_unavailable"]);
10
+ const ERRORS = {
11
+ unauthorized: "Run a2a-exposed login, then run finish again.",
12
+ invalid_request: "Check the agent name, hostname and link options before running the command again.",
13
+ link_exists: "The link request was refused; review the approval page before changing or removing an existing link.",
14
+ apex_hostname: "The domain itself cannot be a secure link. Choose a hostname below it, such as wake.example.com, then run create again.",
15
+ outside_zone: "The hostname is not on a domain in your Cloudflare account. Choose a hostname on your domain, then run create again.",
16
+ existing_dns: "The hostname already has a DNS record. Choose another hostname, then run create again.",
17
+ existing_tunnel: "The hostname is already routed through another tunnel. Choose another hostname, then run create again.",
18
+ provision_failed: "Check the approval page for the failing provisioning phase and cleanup state before running the command again.",
19
+ preset: "Select a valid wake preset with --preset <id>, then run finish again.",
20
+ signing_secret: "Check the local signing-secret source and receiver configuration before running the command again.",
21
+ unreadable: "Reconnect Cloudflare on the website, then run finish again.",
22
+ unavailable: "The service is temporarily unavailable; retry later with fresh approval.",
23
+ };
24
+ const APPROVAL_ERRORS = new Set(["access_denied", "expired_token", "link_not_ready", "invalid_grant", "already_delivered", "connector_not_connected", "not_found", "cloudflare_unavailable"]);
10
25
 
11
- // Deliberately never echo server text, bodies, fetch errors, or child output.
12
- function failure(status, body) {
13
- const code = ERRORS.has(body?.error) ? body.error : `HTTP ${status}`;
14
- return new CliError(`link finish failed (${code}); check the link on the website and run finish again for fresh approval`);
26
+ // Only these public validation templates may cross the server-text boundary.
27
+ // Never echo arbitrary bodies, fetch errors, child output, or credential-bearing fields.
28
+ const VALIDATION_MESSAGES = new Set([
29
+ "Choose a wake preset.", "Choose a wake preset and try again.",
30
+ "Use a hostname on the selected domain.",
31
+ "Use a full hostname such as wake.example.com.",
32
+ "Remove the scheme (https://), port and path. Use a full hostname such as wake.example.com.",
33
+ "Enter the local origin URL, such as http://127.0.0.1:8644.",
34
+ "Use an http or https origin with no path or credentials.",
35
+ "Use a path beginning with /, with no query or fragment.",
36
+ ]);
37
+ function validationDetail(body, hostname) {
38
+ if (typeof body?.message !== "string") return "";
39
+ if (VALIDATION_MESSAGES.has(body.message)) return body.message.replace(/ and try again\.$/, ".");
40
+ const apex = `Use a hostname under ${hostname}, such as wake.${hostname}. The domain itself is not a secure link.`;
41
+ return hostname && body.message === apex ? apex : "";
15
42
  }
16
43
 
17
- export async function finishLink(o, {
44
+ function failure(status, body, { command, hostname }) {
45
+ const code = Object.hasOwn(ERRORS, body?.error) || APPROVAL_ERRORS.has(body?.error) ? body.error : `HTTP ${status}`;
46
+ const detail = ["invalid_request", "apex_hostname"].includes(code) ? validationDetail(body, hostname) : "";
47
+ const message = detail || (Object.hasOwn(ERRORS, code) ? ERRORS[code].replace(/run finish/g, `run ${command}`) : "check the link on the website and rerun the command for fresh approval");
48
+ return new CliError(`link ${command} failed${hostname ? ` for ${hostname}` : ""} (${code}); ${message}`);
49
+ }
50
+
51
+ export function savedLinkSession(origin, session = C.controlSession()) {
52
+ if (controlOrigin(session.origin) !== origin || !session.token)
53
+ throw new CliError("sign in to this service first: run login --control-url <service-origin>, then rerun link setup; environment sessions require both CONTROL_URL and CONTROL_TOKEN");
54
+ return session.token;
55
+ }
56
+
57
+ const CREATE_FIELDS = ["hostname", "origin", "path", "preset"];
58
+
59
+ function createPlan(o) {
60
+ for (const key of CREATE_FIELDS) {
61
+ if (typeof o[key] !== "string" || !o[key]) throw new CliError(`hosted link create requires --${key}`);
62
+ }
63
+ const hostname = o.hostname.trim().toLowerCase();
64
+ if (!/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(hostname))
65
+ throw new CliError("--hostname must be a DNS hostname on your Cloudflare domain");
66
+ let origin;
67
+ try { origin = new URL(o.origin); } catch { throw new CliError("--origin must be an http or https origin"); }
68
+ if (!/^https?:$/.test(origin.protocol) || origin.username || origin.password || origin.pathname !== "/" || origin.search || origin.hash)
69
+ throw new CliError("--origin must be an http or https origin with no path or credentials");
70
+ if (!o.path.startsWith("/") || o.path.startsWith("//") || /[\s?#\\\x00-\x1f\x7f]/.test(o.path) || o.path.length > 1000)
71
+ throw new CliError("--path must begin with / and have no query, fragment or whitespace");
72
+ if (!/^[a-z][a-z0-9-]{0,63}$/.test(o.preset)) throw new CliError("--preset must be a wake preset id");
73
+ return { hostname, origin: origin.origin, path: o.path, preset: o.preset };
74
+ }
75
+
76
+ export function createLink(o, deps) {
77
+ const create = createPlan(o);
78
+ // Catch a wrong local route before asking the owner to create any resources.
79
+ validateSecretSource(o);
80
+ if (o["secret-from"] === "hermes" && (create.preset !== "hermes" || !/^\/webhooks\/[A-Za-z0-9_-]+$/.test(create.path) || (o.route && create.path !== `/webhooks/${o.route}`)))
81
+ throw new CliError("Hermes route does not match the create path and preset");
82
+ return setupLink(o, deps, create);
83
+ }
84
+
85
+ export function finishLink(o, deps) {
86
+ 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");
87
+ return setupLink(o, deps);
88
+ }
89
+
90
+ async function setupLink(o, {
18
91
  fetchImpl = fetch, sleep = sleepDefault, now = Date.now,
19
92
  write = (line) => console.error(line), saveToken = writeTokenFile,
20
93
  readSecret = readSigningSecret, runConnector = startConnector,
21
- } = {}) {
94
+ sessionToken = savedLinkSession,
95
+ } = {}, create) {
22
96
  validateSecretSource(o);
97
+ const context = { command: create ? "create" : "finish", hostname: create?.hostname };
23
98
  const agent = o.agent;
24
99
  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)");
100
+ 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");
25
101
  const origin = controlOrigin(o["control-url"]);
26
102
  if (!origin) throw new CliError("--control-url must be an https origin with no path");
103
+ const session = sessionToken(origin);
27
104
  const request = async (endpoint, body, grant, timeout = 15000) => {
28
105
  let response;
29
106
  try {
30
107
  response = await fetchImpl(`${origin}/api/link-finish/${endpoint}`, {
31
108
  method: body === undefined ? "GET" : "POST", redirect: "error",
32
- headers: { accept: "application/json", ...(body === undefined ? {} : { "content-type": "application/json" }), ...(grant ? { authorization: `Grant ${grant}` } : {}) },
109
+ headers: { accept: "application/json", ...(body === undefined ? {} : { "content-type": "application/json" }), ...(endpoint === "requests" ? { authorization: `Bearer ${session}` } : grant ? { authorization: `Grant ${grant}` } : {}) },
33
110
  ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(timeout),
34
111
  });
35
112
  const text = await response.text();
@@ -37,12 +114,12 @@ export async function finishLink(o, {
37
114
  const parsed = JSON.parse(text);
38
115
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error();
39
116
  return { response, body: parsed };
40
- } catch { throw new CliError("link finish request failed; check the connection and run finish again for fresh approval"); }
117
+ } catch { throw new CliError(`link ${context.command} request failed; check the connection and run ${context.command} again for fresh approval`); }
41
118
  };
42
119
  const verifier = crypto.randomBytes(32).toString("base64url");
43
120
  const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
44
- const started = await request("requests", { agent, challenge });
45
- if (started.response.status !== 201) throw failure(started.response.status, started.body);
121
+ const started = await request("requests", { agent, challenge, ...(create ? { create } : {}) });
122
+ if (started.response.status !== 201) throw failure(started.response.status, started.body, context);
46
123
  const setup = started.body;
47
124
  let approval;
48
125
  try { approval = new URL(setup.approve_url); } catch { throw new CliError("invalid approval response"); }
@@ -57,17 +134,21 @@ export async function finishLink(o, {
57
134
  const token = await request("token", { request_id: setup.request_id, verifier });
58
135
  if (token.response.status === 428 && token.body.error === "authorization_pending") continue;
59
136
  if (token.response.status === 429 && token.body.error === "slow_down") { interval += 5; continue; }
60
- if (!token.response.ok) throw failure(token.response.status, token.body);
137
+ if (!token.response.ok) throw failure(token.response.status, token.body, context);
61
138
  authorized = token.body; break;
62
139
  }
63
- if (!authorized) throw failure(410, { error: "expired_token" });
140
+ if (!authorized) throw failure(410, { error: "expired_token" }, context);
64
141
  const { grant, link } = authorized;
65
- 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" || !link.preset || typeof link.hostname !== "string" || typeof link.origin !== "string")
142
+ 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")
66
143
  throw new CliError("invalid approved link response");
144
+ if (create && CREATE_FIELDS.some((key) => link[key] !== create[key]))
145
+ throw new CliError("approved link differs from the requested creation plan; no connector or signing secret was transferred");
146
+ const preset = o.preset ?? link.preset;
147
+ 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");
67
148
  // Resolve and validate the source before spending the one-time connector delivery.
68
- const secret = readSecret(o, link);
149
+ const secret = readSecret(o, { ...link, preset });
69
150
  const connector = await request("connector", {}, grant);
70
- if (!connector.response.ok) throw failure(connector.response.status, connector.body);
151
+ if (!connector.response.ok) throw failure(connector.response.status, connector.body, context);
71
152
  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");
72
153
  const tokenFile = path.resolve(o["token-file"] || path.join(os.homedir(), ".a2a-exposed", "links", `${agent}.token`));
73
154
  saveToken(tokenFile, connector.body.connector_token);
@@ -81,15 +162,15 @@ export async function finishLink(o, {
81
162
  for (let poll = 0; poll < 20 && now() < connectDeadline; poll++) {
82
163
  const status = await request("status", undefined, grant, Math.min(15000, connectDeadline - now()));
83
164
  if (now() >= connectDeadline) break;
84
- if (!status.response.ok) throw failure(status.response.status, status.body);
165
+ if (!status.response.ok) throw failure(status.response.status, status.body, context);
85
166
  if (status.body.connector === "connected" && Number.isInteger(status.body.connections) && status.body.connections > 0) { connected = true; break; }
86
167
  await sleep(Math.min(3000, Math.max(0, connectDeadline - now())));
87
168
  }
88
169
  if (!connected) throw new CliError("connector did not report connected within 60 seconds; check cloudflared and run finish again for fresh approval");
89
- const tested = await request("wake-test", { signing_secret: secret, preset: link.preset }, grant);
90
- if (!tested.response.ok) throw failure(tested.response.status, tested.body);
170
+ const tested = await request("wake-test", { signing_secret: secret, preset }, grant);
171
+ if (!tested.response.ok) throw failure(tested.response.status, tested.body, context);
91
172
  if (tested.body.result !== "ok" || tested.body.saved !== true) {
92
- const result = ["dns-missing", "access-401", "access-403", "origin-4xx/5xx", "timeout"].includes(tested.body.result) ? tested.body.result : "unknown";
173
+ const result = (["dns-missing", "access-401", "access-403", "timeout"].includes(tested.body.result) || /^origin-\d{3}$/.test(tested.body.result)) ? tested.body.result : "unknown";
93
174
  throw new CliError(`wake test did not save settings (${result}); fix the receiver and run finish again for fresh approval`);
94
175
  }
95
176
  write("Connector connected. Wake test passed and settings saved.");
package/lib/main.mjs CHANGED
@@ -12,7 +12,7 @@ import * as account from "./account.mjs";
12
12
  import * as upd from "./update.mjs";
13
13
  import * as fb from "./feedback.mjs";
14
14
  import * as un from "./uninstall.mjs";
15
- import { finishLink } from "./link-finish.mjs";
15
+ import { finishLink, createLink } from "./link-finish.mjs";
16
16
 
17
17
  const VERSION = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
18
18
 
@@ -23,19 +23,12 @@ Usage: npx -y a2a-exposed@latest <command> [options]
23
23
  --version, -v print the version
24
24
  --help, -h print this help
25
25
 
26
- hosted link finish --agent NAME --control-url https://control.example
27
- (--secret-file FILE | --secret-env VAR | --secret-from hermes)
28
- [--hermes-home DIR] [--route NAME] [--token-file FILE] [--no-run]
29
- Human approves the printed URL/code. Saves the connector token privately,
30
- starts cloudflared, waits up to 60 seconds, then tests and saves the wake.
31
- --control-url defaults to saved CONTROL_URL. No secrets in arguments/output.
32
- --no-run saves the token and prints the connector command; wake is not saved.
33
- Recovery: rerun with fresh approval; the ready link is reused.
26
+ Setup options: --skip-install skips dependency installation when dependencies already exist.
34
27
 
35
28
  Setup (needs Node 22.18+, e.g. \`mise exec node@22 -- npx -y a2a-exposed@latest ...\`, and a Cloudflare login:
36
29
  npx cf auth login --no-browser; on a VPS where it fails with HTTP 403 before showing a code, export
37
30
  CLOUDFLARE_API_TOKEN instead: setup skill, Troubleshooting. a2a.exposed hosts the A2A endpoint and is open to everyone
38
- (https://a2a.exposed/pricing). When to use it, and the sentence to say:
31
+ (https://a2a.exposed/pricing). When to use it:
39
32
  https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md#hosted-inbox)
40
33
  init [--hostname <host> | --workers-dev [--workers-dev-subdomain NAME]]
41
34
  [--agent-name N] [--agent-description D] [--agent-skills JSON]
@@ -51,6 +44,7 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
51
44
  --workers-dev move an existing custom-domain deployment to workers.dev (and
52
45
  --hostname H moves it back); the old URL answers 301/410
53
46
  --workers-dev-subdomain create the account's workers.dev subdomain if missing
47
+ Uses system script on macOS/Linux, including under automation.
54
48
  --cf-profile cf auth profile for a separate Cloudflare login
55
49
  (npx cf auth create NAME --no-browser); saved, used by every cf call.
56
50
  When whoami lists no accounts, init reads cf accounts list
@@ -60,6 +54,8 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
60
54
  the owner token via \`pair approve\`, or the approval password
61
55
  on the /device page; agent: the agent runs \`pair approve\` after
62
56
  the human says yes; off: no pairing endpoints, \`token issue\` only)
57
+ init reports readiness when password approval is enabled; when missing, use
58
+ \`pair set-password --web\` for the human-owned setup page.
63
59
  --pbkdf2-iterations approval-password hashing cost, 50000-100000 (default 100000,
64
60
  the Workers maximum; lower only if /device hits error 1102)
65
61
  --workers-logs on|off persisted Cloudflare Workers Logs (query strings redacted)
@@ -281,7 +277,7 @@ Owner
281
277
  the MCP consent page when the issuer, client id, client secret, and allowlist are all set.
282
278
 
283
279
  Feedback
284
- feedback "message" [--kind bug|idea|other] [--contact x]
280
+ feedback "message" [--json] [--kind bug|idea|other] [--contact x]
285
281
  [--steps S] [--expected S] [--actual S] [--logs S] [--agent A]
286
282
  [--environment local|cloud VM|CI|cloud agent sandbox|other]
287
283
  [--connection webhook|proxy|polling] [--yes]
@@ -293,9 +289,9 @@ Feedback
293
289
  confidential information: secrets, tokens, owner or pairing
294
290
  tokens, private URLs or hostnames, message contents, or
295
291
  personal data. Prints that warning and asks before sending,
296
- unless --yes is set. Off a terminal, --yes is required.
292
+ unless --yes is set. Off a terminal, posting requires --yes.
297
293
  Posts when this A2A endpoint names a feedback URL.
298
- Otherwise prints a link to the bug
294
+ Otherwise prints Not filed and a link to the bug
299
295
  report form and does not open a browser. The CLI adds its
300
296
  version, the A2A endpoint Worker version when the A2A endpoint reports it,
301
297
  the OS, the Node version, the connection (webhook, proxy, or
@@ -303,7 +299,7 @@ Feedback
303
299
  that detection. Remove tokens from the message and from --logs.
304
300
 
305
301
  Presets: grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | openbot | generic
306
- Which clients are tested end to end is in the client table:
302
+ Clients and how to load them:
307
303
  https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md#clients
308
304
  Config: ${C.CONFIG_FILE} (override dir with --config-dir DIR or A2A_CONFIG_DIR; env vars override file values)
309
305
  Update notice: at most a daily background check of the npm registry; off with A2A_NO_UPDATE_CHECK=1 (or DO_NOT_TRACK=1)`;
@@ -349,7 +345,7 @@ const deployOpts = {
349
345
  "workers-dev": B, "workers-dev-subdomain": S, "upstream-token-stdin": B, "no-upstream-token": B, "event-secret-stdin": B,
350
346
  };
351
347
  const SPEC = {
352
- hosted: { agent: S, "control-url": S, "secret-file": S, "secret-env": S, "secret-from": S, "hermes-home": S, route: S, "token-file": S, "no-run": B },
348
+ hosted: { 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 },
353
349
  init: { ...deployOpts, ...tunnelOpts, tunnel: B }, deploy: deployOpts, wake: deployOpts, tunnel: tunnelOpts,
354
350
  inbox: { context: S, from: S, all: B, json: B },
355
351
  reply: { text: S, stdin: B, state: { type: "string", default: "completed" }, artifact: B, "artifact-name": { type: "string", default: "response" }, force: B },
@@ -363,10 +359,11 @@ const SPEC = {
363
359
  export: { sql: B },
364
360
  upstream: { json: B },
365
361
  connect: { alias: S, name: S, json: B, "no-wait": B, replace: B, force: B, "card-url": S },
362
+ // Internal hosted-service integration commands. Keep out of public help until the production flow is available.
366
363
  signup: { "control-url": S, json: B },
367
364
  login: { "control-url": S, json: B },
368
365
  pair: { json: B, web: B, ttl: S, issuer: S, "client-id": S, subjects: S, methods: S, "secret-stdin": B },
369
- feedback: { kind: S, contact: S, steps: S, expected: S, actual: S, logs: S, agent: S, environment: S, connection: S, yes: B },
366
+ feedback: { kind: S, contact: S, steps: S, expected: S, actual: S, logs: S, agent: S, environment: S, connection: S, yes: B, json: B },
370
367
  uninstall: { yes: B, "dry-run": B, dir: S, "worker-dir": S },
371
368
  };
372
369
  const STATES = ["completed", "input-required", "failed", "rejected", "working"];
@@ -410,7 +407,7 @@ export async function main(argv) {
410
407
  if (e && e.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION") {
411
408
  const flag = (String(e.message).match(/'([^']+)'/) || [])[1] || "that flag";
412
409
  if (flag === "-y") throw new CliError("unknown flag -y. To skip a prompt, pass --yes.");
413
- throw new CliError(`unknown flag ${flag}. Run \`${CLI} ${name} --help\` for this command's flags.`);
410
+ throw new CliError(`unknown flag ${flag}. Run \`${CLI} --help\` and see the ${name} section.`);
414
411
  }
415
412
  if (e && String(e.code || "").startsWith("ERR_PARSE_ARGS")) throw new CliError(String(e.message));
416
413
  throw e;
@@ -472,8 +469,8 @@ export async function main(argv) {
472
469
  throw new CliError(`unknown peer action ${sub} (announce|forward)`);
473
470
  }
474
471
  case "hosted": {
475
- if (p.length !== 2 || p[0] !== "link" || p[1] !== "finish") throw new CliError("usage: hosted link finish --agent NAME (--secret-file FILE | --secret-env VAR | --secret-from hermes)");
476
- return finishLink({ ...o, "control-url": o["control-url"] || C.get("CONTROL_URL") });
472
+ 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)");
473
+ return (p[1] === "create" ? createLink : finishLink)({ ...o, "control-url": o["control-url"] || C.get("CONTROL_URL") });
477
474
  }
478
475
  case "signup":
479
476
  case "login": {
@@ -495,21 +492,22 @@ export async function main(argv) {
495
492
  case "outbound": return cmd.outbound(need(p[0], "outbound <taskId>"));
496
493
  case "feedback": {
497
494
  const message = p.join(" ").trim();
498
- need(message, `feedback "message" [--kind bug|idea|other] [--steps S] [--expected S] [--actual S] [--logs S] [--agent A] [--environment E] [--connection webhook|proxy|polling]`);
495
+ need(message, `feedback "message" [--json] [--kind bug|idea|other] [--steps S] [--expected S] [--actual S] [--logs S] [--agent A] [--environment E] [--connection webhook|proxy|polling]`);
499
496
  const kind = o.kind || "other";
500
497
  if (!fb.KINDS.includes(kind)) throw new CliError("--kind must be bug, idea, or other");
501
498
  if (o.connection && !fb.CONNECTIONS.includes(o.connection)) throw new CliError("--connection must be webhook, proxy, or polling");
502
499
  if (kind === "bug" && !String(o.steps || "").trim()) throw new CliError("a bug report needs reproduction steps (--steps)");
503
500
  const repo = await fb.feedbackPublicRepo({ baseUrl: C.get("A2A_BASE_URL"), ownerToken: C.get("A2A_OWNER_TOKEN") });
504
- await fb.confirmPublicIssue(Boolean(o.yes), undefined, undefined, repo);
505
501
  const result = await fb.runFeedback({
502
+ beforePost: () => fb.confirmPublicIssue(Boolean(o.yes), undefined, undefined, repo),
506
503
  message, kind, contact: o.contact || "", version: VERSION,
507
504
  steps: o.steps || "", expected: o.expected || "", actual: o.actual || "", logs: o.logs || "",
508
505
  agent: o.agent || "", environment: o.environment || "",
509
506
  connection: o.connection || "",
510
507
  baseUrl: C.get("A2A_BASE_URL"), ownerToken: C.get("A2A_OWNER_TOKEN"),
511
508
  });
512
- console.log(result.url);
509
+ if (!result.filed) console.error("Not filed: this A2A endpoint has no feedback inbox. Open this link to file it on GitHub:");
510
+ console.log(o.json ? JSON.stringify(result.filed ? { filed: true, issue: result.url } : result) : result.url);
513
511
  return;
514
512
  }
515
513
  default: throw new CliError(`unknown command ${name} (see --help)`);
package/lib/script.mjs ADDED
@@ -0,0 +1,56 @@
1
+ // Node's "pipe" stdin is a socket on macOS. BSD script refuses its ioctl
2
+ // error, so relay stdin through a real shell pipe before allocating the PTY.
3
+ import { spawn } from "node:child_process";
4
+
5
+ export function spawnScript(args, options = {}) {
6
+ if (process.platform !== "darwin") return forwardShutdown(spawn("script", args, { ...options, detached: true, stdio: ["pipe", "pipe", "pipe"] }));
7
+ // All command arguments remain positional, never interpolated into shell code.
8
+ // fd 3 reports script completion so cat cannot keep the wrapper alive waiting
9
+ // for input. The terminal job cannot write that channel or inherit it.
10
+ const relay = '/bin/cat | { "$@" 3>&-; result=$?; printf "%s\\n" "$result" >&3; exit "$result"; }';
11
+ const child = spawn("/bin/sh", ["-c", relay, "a2a-script", "/usr/bin/script", ...args], {
12
+ ...options, detached: true, stdio: ["pipe", "pipe", "pipe", "pipe"],
13
+ });
14
+ child.stdio[3].once("data", () => child.stdin.end());
15
+ child.stdio[3].resume();
16
+ child.stdin.on("error", () => {}); // script can exit just before an answer.
17
+ return forwardShutdown(child);
18
+ }
19
+
20
+ export function stopScript(child, signal = "SIGTERM") {
21
+ if (!child.pid) return false;
22
+ try {
23
+ // Stop the relay and script together. Closing the PTY master also hangs
24
+ // up the terminal job rather than leaving it waiting for an answer.
25
+ process.kill(-child.pid, signal);
26
+ return true;
27
+ } catch (error) {
28
+ if (error.code === "ESRCH") return false;
29
+ throw error;
30
+ }
31
+ }
32
+
33
+ // Detached PTYs do not receive the CLI's terminal signals. Forward shutdown
34
+ // before allowing the parent's usual signal behavior, and release all hooks
35
+ // on spawn failure or completion so repeated commands do not retain children.
36
+ function forwardShutdown(child) {
37
+ const handlers = new Map();
38
+ const cleanup = () => {
39
+ for (const [signal, handler] of handlers) process.removeListener(signal, handler);
40
+ process.removeListener("exit", onExit);
41
+ };
42
+ const onExit = () => stopScript(child);
43
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
44
+ const handler = () => {
45
+ stopScript(child, signal);
46
+ cleanup();
47
+ if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
48
+ };
49
+ handlers.set(signal, handler);
50
+ process.on(signal, handler);
51
+ }
52
+ process.on("exit", onExit);
53
+ child.once("error", cleanup);
54
+ child.once("close", cleanup);
55
+ return child;
56
+ }
@@ -1,6 +1,6 @@
1
1
  // workers.dev support: URL helpers, cf output parsing, and registering the account's workers.dev subdomain
2
2
  // through cf's own interactive prompt (cf has no non-interactive command for it).
3
- import { spawn } from "node:child_process";
3
+ import { spawnScript, stopScript } from "./script.mjs";
4
4
  import { CliError, die } from "./a2a.mjs";
5
5
 
6
6
  /** A valid DNS label: worker names and workers.dev subdomains must be one. */
@@ -65,12 +65,18 @@ export function deployWithRegistration(cfBin, args, name, { cwd, env, idleMs = 1
65
65
  const penv = { ...env, TERM: env?.TERM || "xterm-256color", COLUMNS: "160", LINES: "50" };
66
66
  delete penv.CI; // cf only prompts when interactive
67
67
  return new Promise((resolve, reject) => {
68
- const ch = spawn("script", sargs, { cwd, env: penv, stdio: ["pipe", "pipe", "pipe"] });
68
+ const ch = spawnScript(sargs, { cwd, env: penv });
69
69
  // cf's prompts, in order; each is answered once, and only from output that arrived after the previous
70
70
  // answer (prompt libraries re-render answered questions, which must not be answered twice)
71
71
  const steps = REGISTRATION_STEPS(name);
72
- let out = "", pending = "", stage = 0, failure = "";
73
- const stop = (why) => { failure ||= why; ch.kill("SIGTERM"); };
72
+ let out = "", pending = "", stage = 0, failure = "", escalation;
73
+ const stop = (why) => {
74
+ if (failure) return;
75
+ failure = why;
76
+ stopScript(ch);
77
+ escalation = setTimeout(() => stopScript(ch, "SIGKILL"), 1000);
78
+ escalation.unref();
79
+ };
74
80
  const arm = () => setTimeout(() => stop("cf stopped responding (unexpected prompt?)"), idleMs);
75
81
  let idle = arm();
76
82
  const onData = (d) => {
@@ -86,9 +92,10 @@ export function deployWithRegistration(cfBin, args, name, { cwd, env, idleMs = 1
86
92
  };
87
93
  ch.stdout.on("data", onData);
88
94
  ch.stderr.on("data", onData);
89
- ch.on("error", (e) => { clearTimeout(idle); reject(e); });
95
+ ch.on("error", (e) => { clearTimeout(idle); clearTimeout(escalation); reject(e); });
90
96
  ch.on("close", (code) => {
91
97
  clearTimeout(idle);
98
+ clearTimeout(escalation);
92
99
  ch.stdin.destroy();
93
100
  if (failure) return reject(new CliError(failure));
94
101
  resolve({ status: code ?? 1, out });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.8.27",
3
+ "version": "0.8.28",
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": {
@@ -2445,7 +2445,7 @@ async function deviceAuthorization(req: Request, ctx: TenantContext, ectx: Execu
2445
2445
  if (!(await wakeBudgetOk(ctx))) return log("wake_skipped", { reason: "hourly cap", kind: "pairing_request" });
2446
2446
  const identityFingerprint = identity ? await A.fingerprint(identity) : null;
2447
2447
  await sendWake(ctx, ectx, { contextId: "pairing", taskId: "none", taskIds: [], from: clientName || clientId || "unknown agent", preview: "",
2448
- kind: "pairing_request", pairing: { userCode: shown, verificationUriComplete: complete, approval: mode, clientName, clientId, agentCardUrl: cardUrl,
2448
+ kind: "pairing_request", pairing: { passwordSet: !!(await approvalPassword(ctx)), passwordEnabled: O.approvalPolicy(ctx.gates.approvalOidc).password, userCode: shown, verificationUriComplete: complete, approval: mode, clientName, clientId, agentCardUrl: cardUrl,
2449
2449
  ...(cardUrl && P.cardIsPrivate(cardUrl) ? { agentCardPrivate: true } : {}),
2450
2450
  ...(identityFingerprint ? { identityFingerprint } : {}), expiresIn: P.EXPIRES_S,
2451
2451
  ...(manage ? { manageUrl: manage } : {}),
@@ -1,2 +1,2 @@
1
1
  /** Inbox build. Keep this equal to `version` in cli/package.json. */
2
- export const VERSION = "0.8.27";
2
+ export const VERSION = "0.8.28";
@@ -23,6 +23,8 @@ export type PairingInfo = {
23
23
  agentCardUrl: string;
24
24
  agentCardPrivate?: boolean; // the claimed card is on a private network (Tailnet, LAN): informational, not reachable
25
25
  expiresIn: number;
26
+ passwordSet?: boolean; // readiness only; never the password or its hash
27
+ passwordEnabled?: boolean; // effective approval policy, including incomplete-OIDC fallback
26
28
  /** Dashboard page for this agent. Present only when MANAGE_URL is an https URL. */
27
29
  manageUrl?: string;
28
30
  replacesLabel?: string; // re-pairing: the requester proved it holds this active token; approval replaces it
@@ -78,16 +80,18 @@ export function wakePayload(ev: WakeEvent, cli: string): Record<string, unknown>
78
80
  };
79
81
  }
80
82
 
81
- /** What the woken agent must do with a pairing request: ask its human, never approve on its own. */
83
+ /** What the woken agent must do with a pairing request: ask its owner, never approve on its own. */
82
84
  export function pairingInstructions(p: PairingInfo, cli: string): string {
85
+ const readiness = p.passwordSet === false && p.passwordEnabled !== false
86
+ ? ` Approval password: not set. To approve pairing on /device with a password, run: ${cli} pair set-password --web. Send the one-time setup link privately to your human; they set the password themselves. Never open the page or ask for the password.` : "";
83
87
  const dash = p.manageUrl ? ` The dashboard page for this request is ${p.manageUrl}.` : "";
84
88
  return p.approval === "human"
85
89
  ? `Ask your human; never approve on your own. Show them the code ${p.userCode} and give them this link: ${p.verificationUriComplete} . ` +
86
90
  `They approve there with their approval password, or with \`${cli} pair approve ${p.userCode}\` (the owner token), after checking the code with the other agent's owner. Deny with \`${cli} pair deny ${p.userCode}\`. Details: \`${cli} pair list\`.` +
87
- dash
91
+ dash + readiness
88
92
  : `Ask your human; never approve on your own. Show them the code ${p.userCode}, who is asking, and the link ${p.verificationUriComplete} . ` +
89
93
  `Only if they say yes, run \`${cli} pair approve ${p.userCode}\`; if they say no (or don't answer), \`${cli} pair deny ${p.userCode}\`.` +
90
- dash;
94
+ dash + readiness;
91
95
  }
92
96
 
93
97
  function hintFor(ev: WakeEvent, cli: string): string {
@@ -272,6 +272,8 @@ test("human approval: pending, slow_down, page approve with the password, single
272
272
  assert.equal(w.pairing.agentCardUrl, "https://barry.example.org/.well-known/agent-card.json");
273
273
  assert.equal("manageUrl" in w.pairing, false);
274
274
  assert.match(w.pairing.instructions, /never approve on your own[\s\S]*give them this link/);
275
+ assert.equal(w.pairing.passwordSet, false);
276
+ assert.match(w.pairing.instructions, /Approval password: not set[\s\S]*pair set-password --web/);
275
277
 
276
278
  let p = await s.poll(d.device_code);
277
279
  assert.deepEqual([p.status, p.data.error, p.data.error_description], [400, "authorization_pending", "waiting for the agent owner to approve"]);
@@ -971,3 +973,38 @@ test("approval page shows the visitor's clock, and an https MANAGE_URL is on the
971
973
  await bad.start(undefined, "203.0.113.82");
972
974
  assert.equal("manageUrl" in bad.wakes[0].body.pairing, false);
973
975
  });
976
+
977
+ test("pairing wake omits setup guidance once a password exists", async (t) => {
978
+ const s = setup(); t.after(s.restore);
979
+ await s.owner("PUT", "/owner/pairing/password", await passwordRecord());
980
+ assert.equal((await s.start()).status, 200);
981
+ const pairing = s.wakes[0].body.pairing;
982
+ assert.equal(pairing.passwordSet, true);
983
+ assert.doesNotMatch(pairing.instructions, /pair set-password|Approval password: not set/);
984
+ assert.doesNotMatch(JSON.stringify(s.wakes), /pbkdf2|correct horse|salt|hash/);
985
+ });
986
+
987
+ test("pairing readiness reaches text wakes without requester text on the trusted preset", async () => {
988
+ const ev: WakeEvent = { contextId: "pairing", taskId: "none", taskIds: [], from: "Untrusted", preview: "", kind: "pairing_request", publicUrl: BASE,
989
+ pairing: { userCode: "WDJB-2345", verificationUriComplete: `${BASE}/device?user_code=WDJB-2345`, approval: "human", clientName: "Untrusted", clientId: "untrusted", agentCardUrl: "", expiresIn: 3600, passwordSet: false } };
990
+ for (const preset of ["claude-code", "openclaw-wake", "openclaw-agent"]) {
991
+ const request = (await renderWake({ preset, url: "https://hook.example.net/wake", cliCommand: "a2a" }, ev))!;
992
+ const body = JSON.parse(request.body);
993
+ assert.match(body.text || body.message, /Approval password: not set[\s\S]*a2a pair set-password --web/);
994
+ if (preset === "openclaw-wake") assert.doesNotMatch(body.text, /Untrusted/);
995
+ }
996
+ });
997
+
998
+ for (const [name, config, enabled] of [
999
+ ["OIDC only", { APPROVAL_METHODS: "oidc", APPROVAL_OIDC_ISSUER: "https://idp.example", APPROVAL_OIDC_CLIENT_ID: "client-1", APPROVAL_OIDC_CLIENT_SECRET: "dummy-secret", APPROVAL_OIDC_ALLOWED_SUBJECTS: "owner-1" }, false],
1000
+ ["incomplete OIDC fallback", { APPROVAL_METHODS: "oidc", APPROVAL_OIDC_ISSUER: "https://idp.example", APPROVAL_OIDC_CLIENT_ID: "client-1" }, true],
1001
+ ["password and OIDC", { APPROVAL_METHODS: "password,oidc", APPROVAL_OIDC_ISSUER: "https://idp.example", APPROVAL_OIDC_CLIENT_ID: "client-1", APPROVAL_OIDC_CLIENT_SECRET: "dummy-secret", APPROVAL_OIDC_ALLOWED_SUBJECTS: "owner-1" }, true],
1002
+ ] as const) test(`pairing wake readiness respects ${name}`, async (t) => {
1003
+ const s = setup(config); t.after(s.restore);
1004
+ assert.equal((await s.start()).status, 200);
1005
+ const pairing = s.wakes[0].body.pairing;
1006
+ assert.equal(pairing.passwordSet, false);
1007
+ assert.equal(pairing.passwordEnabled, enabled);
1008
+ if (enabled) assert.match(pairing.instructions, /Approval password: not set[\s\S]*pair set-password --web/);
1009
+ else assert.doesNotMatch(pairing.instructions, /Approval password: not set|pair set-password --web/);
1010
+ });