a2a-exposed 0.8.27 → 0.8.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/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,170 @@ 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
+ expired_token: "The link request expired. Start a new one with hosted link create.",
12
+ unauthorized: "Run a2a-exposed login, then run finish again.",
13
+ invalid_request: "Check the agent name, hostname and link options before running the command again.",
14
+ link_exists: "The link request was refused; review the approval page before changing or removing an existing link.",
15
+ apex_hostname: "The domain itself cannot be a secure link. Choose a hostname below it, such as wake.example.com, then run create again.",
16
+ outside_zone: "The hostname is not on a domain in your Cloudflare account. Choose a hostname on your domain, then run create again.",
17
+ existing_dns: "The hostname already has a DNS record. Choose another hostname, then run create again.",
18
+ existing_tunnel: "The hostname is already routed through another tunnel. Choose another hostname, then run create again.",
19
+ provision_failed: "Check the approval page for the failing provisioning phase and cleanup state before running the command again.",
20
+ preset: "Select a valid wake preset with --preset <id>, then run finish again.",
21
+ signing_secret: "Check the local signing-secret source and receiver configuration before running the command again.",
22
+ unreadable: "The secure link access key could not be read. Remove the secure link on the website, then run create again.",
23
+ slow_down: "Too many pending requests for this agent. Deny an earlier request on the website or wait for it to expire, then run finish again.",
24
+ access_denied: "The request was denied. Run finish again only if you still want to set up this link.",
25
+ link_not_ready: "The secure link is not ready. Finish creating it in Wake settings on the website, then run finish again.",
26
+ invalid_grant: "This grant is not valid. Run finish again and approve a new request.",
27
+ already_delivered: "Credentials were already delivered for this request. Run finish again for fresh approval if you did not receive them.",
28
+ connector_not_connected: "The connector is not connected. Start cloudflared with the saved token file, then run finish again for fresh approval.",
29
+ not_found: "That agent or request was not found. Check --agent on the website, then run finish again.",
30
+ cloudflare_unavailable: "Cloudflare could not be reached. Reconnect Cloudflare in Wake settings on the website, then run finish again.",
31
+ unavailable: "The service is temporarily unavailable; retry later with fresh approval.",
32
+ };
10
33
 
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`);
34
+ // Fixed local guidance only: wake results must never expose server text.
35
+ const WAKE_NEXT_STEPS = {
36
+ none: "Update the hosted Worker, then run finish again for fresh approval.",
37
+ "dns-missing": "Wait for DNS propagation, then run finish again for fresh approval.",
38
+ "access-401": "Check the signing secret and Access service-token pair, then run finish again for fresh approval.",
39
+ "access-403": "Check the signing secret and Access service-token pair, then run finish again for fresh approval.",
40
+ timeout: "Retry later by running finish again for fresh approval.",
41
+ "origin-429": "Retry later by running finish again for fresh approval.",
42
+ "origin-5xx": "Retry later by running finish again for fresh approval.",
43
+ "origin-NNN": "Check the receiver's HTTP response, then run finish again for fresh approval.",
44
+ tls: "Fix the receiver's TLS certificate, then run finish again for fresh approval.",
45
+ private: "Use a public HTTPS wake URL, then run finish again for fresh approval.",
46
+ dns: "Check the wake hostname's DNS records, then run finish again for fresh approval.",
47
+ connection: "Check that the receiver is reachable, then run finish again for fresh approval.",
48
+ unreadable: "Remove the secure link on the website, then run create again.",
49
+ failed: "Check the receiver configuration, then run finish again for fresh approval.",
50
+ unknown: "Update the CLI (npx a2a-exposed@latest) and send feedback.",
51
+ };
52
+
53
+ function wakeFailure(raw) {
54
+ const value = typeof raw === "string" ? raw : "";
55
+ const normalized = /^[1-5]\d{2}$/.test(value) ? `origin-${value}` : value;
56
+ const result = Object.hasOwn(WAKE_NEXT_STEPS, normalized) && !["origin-5xx", "origin-NNN"].includes(normalized)
57
+ ? normalized : /^origin-\d{3}$/.test(normalized) ? normalized : "unknown";
58
+ const guidance = WAKE_NEXT_STEPS[result] ?? WAKE_NEXT_STEPS[/^origin-5\d{2}$/.test(result) ? "origin-5xx" : "origin-NNN"];
59
+ return { result, message: `wake test of the new secure link failed (${result}); saved wake settings were not changed. ${guidance}` };
60
+ }
61
+
62
+ // Only these public validation templates may cross the server-text boundary.
63
+ // Never echo arbitrary bodies, fetch errors, child output, or credential-bearing fields.
64
+ const VALIDATION_MESSAGES = new Set([
65
+ "Choose a wake preset.", "Choose a wake preset and try again.",
66
+ "Use a hostname on the selected domain.",
67
+ "Use a full hostname such as wake.example.com.",
68
+ "Remove the scheme (https://), port and path. Use a full hostname such as wake.example.com.",
69
+ "Enter the local origin URL, such as http://127.0.0.1:8644.",
70
+ "Use an http or https origin with no path or credentials.",
71
+ "Use a path beginning with /, with no query or fragment.",
72
+ ]);
73
+ const DOMAIN = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
74
+ function validationDetail(body, hostname) {
75
+ if (typeof body?.message !== "string") return "";
76
+ if (VALIDATION_MESSAGES.has(body.message)) return body.message.replace(/ and try again\.$/, ".");
77
+ if (body.reason === "outside_zone" || body.error === "outside_zone") {
78
+ const prefix = "That hostname is not on a domain in your Cloudflare account. ";
79
+ if (body.message === prefix + "Add a domain to Cloudflare, then run create again.") return body.message;
80
+ const match = body.message.match(/^That hostname is not on a domain in your Cloudflare account\. Use a hostname under (.+)\.$/);
81
+ if (match && match[1].split(" or ").every((domain) => DOMAIN.test(domain))) return body.message;
82
+ }
83
+ const apex = `Use a hostname under ${hostname}, such as wake.${hostname}. The domain itself is not a secure link.`;
84
+ return hostname && body.message === apex ? apex : "";
85
+ }
86
+
87
+ function provisionGuidance(body) {
88
+ if (body.phase === "cleanup") return "Remove the unfinished secure link on the website, then run create again.";
89
+ if (["hostname", "zone", "existing_dns", "existing_tunnel"].includes(body.phase))
90
+ return "Choose another hostname on a domain in your Cloudflare account, then run create again.";
91
+ if (body.status === 401 || body.status === 403)
92
+ return "Cloudflare refused access. Reconnect Cloudflare on the website, then run create again.";
93
+ if (body.status === 429 || (Number.isInteger(body.status) && body.status >= 500 && body.status <= 599))
94
+ return "Cloudflare was busy. Run create again in a few minutes.";
95
+ return ERRORS.provision_failed;
96
+ }
97
+
98
+ function failure(status, body, { command, hostname }, phase = "requests") {
99
+ const known = (value) => typeof value === "string" && Object.hasOwn(ERRORS, value);
100
+ const code = known(body?.reason) ? body.reason : known(body?.error) ? body.error : `HTTP ${status}`;
101
+ const detail = phase === "requests" && ["invalid_request", "apex_hostname", "outside_zone"].includes(code) ? validationDetail(body, hostname) : "";
102
+ let guidance = code === "provision_failed" ? provisionGuidance(body) : Object.hasOwn(ERRORS, code) ? ERRORS[code] : "check the link on the website and rerun the command for fresh approval";
103
+ if (code === "expired_token") guidance = ["grant", "connector"].includes(phase)
104
+ ? `The approval grant expired. Run hosted link finish again for fresh approval; ${phase === "grant" ? "the saved connector token remains on disk and " : ""}wake settings were not saved.`
105
+ : `The link request expired. Start a new one with hosted link ${command}.`;
106
+ if (code === "invalid_request" && phase === "token") guidance = `The approval polling request was refused. Run hosted link ${command} again for fresh approval.`;
107
+ const message = detail || guidance.replace(/run finish/gi, (match) => `${match.startsWith("R") ? "Run" : "run"} ${command}`);
108
+ return new CliError(`link ${command} failed${hostname ? ` for ${hostname}` : ""} (${code}); ${message}`);
109
+ }
110
+
111
+ export function savedLinkSession(origin, session = C.controlSession(), command = "finish") {
112
+ 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`);
114
+ return session.token;
115
+ }
116
+
117
+ const CREATE_FIELDS = ["hostname", "origin", "path", "preset"];
118
+
119
+ function createPlan(o) {
120
+ for (const key of CREATE_FIELDS) {
121
+ if (typeof o[key] !== "string" || !o[key]) throw new CliError(`hosted link create requires --${key}`);
122
+ }
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");
126
+ let origin;
127
+ try { origin = new URL(o.origin); } catch { throw new CliError("--origin must be an http or https origin"); }
128
+ 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");
130
+ 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");
133
+ return { hostname, origin: origin.origin, path: o.path, preset: o.preset };
134
+ }
135
+
136
+ export function createLink(o, deps) {
137
+ const create = createPlan(o);
138
+ // Catch a wrong local route before asking the owner to create any resources.
139
+ validateSecretSource(o);
140
+ 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");
142
+ return setupLink(o, deps, create);
143
+ }
144
+
145
+ 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");
147
+ return setupLink(o, deps);
15
148
  }
16
149
 
17
- export async function finishLink(o, {
150
+ async function setupLink(o, {
18
151
  fetchImpl = fetch, sleep = sleepDefault, now = Date.now,
19
152
  write = (line) => console.error(line), saveToken = writeTokenFile,
20
153
  readSecret = readSigningSecret, runConnector = startConnector,
21
- } = {}) {
154
+ sessionToken = savedLinkSession,
155
+ } = {}, create) {
22
156
  validateSecretSource(o);
157
+ const context = { command: create ? "create" : "finish", hostname: create?.hostname };
23
158
  const agent = o.agent;
24
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");
25
161
  const origin = controlOrigin(o["control-url"]);
26
162
  if (!origin) throw new CliError("--control-url must be an https origin with no path");
163
+ const session = sessionToken(origin, undefined, context.command);
27
164
  const request = async (endpoint, body, grant, timeout = 15000) => {
28
165
  let response;
29
166
  try {
30
167
  response = await fetchImpl(`${origin}/api/link-finish/${endpoint}`, {
31
168
  method: body === undefined ? "GET" : "POST", redirect: "error",
32
- headers: { accept: "application/json", ...(body === undefined ? {} : { "content-type": "application/json" }), ...(grant ? { authorization: `Grant ${grant}` } : {}) },
169
+ headers: { accept: "application/json", ...(body === undefined ? {} : { "content-type": "application/json" }), ...(endpoint === "requests" ? { authorization: `Bearer ${session}` } : grant ? { authorization: `Grant ${grant}` } : {}) },
33
170
  ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(timeout),
34
171
  });
35
172
  const text = await response.text();
@@ -37,12 +174,12 @@ export async function finishLink(o, {
37
174
  const parsed = JSON.parse(text);
38
175
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error();
39
176
  return { response, body: parsed };
40
- } catch { throw new CliError("link finish request failed; check the connection and run finish again for fresh approval"); }
177
+ } catch { throw new CliError(`link ${context.command} request failed; check the connection and run ${context.command} again for fresh approval`); }
41
178
  };
42
179
  const verifier = crypto.randomBytes(32).toString("base64url");
43
180
  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);
181
+ const started = await request("requests", { agent, challenge, ...(create ? { create } : {}) });
182
+ if (started.response.status !== 201) throw failure(started.response.status, started.body, context);
46
183
  const setup = started.body;
47
184
  let approval;
48
185
  try { approval = new URL(setup.approve_url); } catch { throw new CliError("invalid approval response"); }
@@ -57,17 +194,21 @@ export async function finishLink(o, {
57
194
  const token = await request("token", { request_id: setup.request_id, verifier });
58
195
  if (token.response.status === 428 && token.body.error === "authorization_pending") continue;
59
196
  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);
197
+ if (!token.response.ok) throw failure(token.response.status, token.body, context, "token");
61
198
  authorized = token.body; break;
62
199
  }
63
- if (!authorized) throw failure(410, { error: "expired_token" });
200
+ if (!authorized) throw failure(410, { error: "expired_token" }, context, "token");
64
201
  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")
202
+ if (typeof grant !== "string" || !/^[\x21-\x7e]+$/.test(grant) || grant.length > 4096 || link?.agent !== agent || typeof link.path !== "string" || !link.path.startsWith("/") || typeof link.preset !== "string" || typeof link.hostname !== "string" || typeof link.origin !== "string")
66
203
  throw new CliError("invalid approved link response");
204
+ 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");
67
208
  // Resolve and validate the source before spending the one-time connector delivery.
68
- const secret = readSecret(o, link);
209
+ const secret = readSecret(o, { ...link, preset });
69
210
  const connector = await request("connector", {}, grant);
70
- if (!connector.response.ok) throw failure(connector.response.status, connector.body);
211
+ if (!connector.response.ok) throw failure(connector.response.status, connector.body, context, "connector");
71
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");
72
213
  const tokenFile = path.resolve(o["token-file"] || path.join(os.homedir(), ".a2a-exposed", "links", `${agent}.token`));
73
214
  saveToken(tokenFile, connector.body.connector_token);
@@ -81,17 +222,19 @@ export async function finishLink(o, {
81
222
  for (let poll = 0; poll < 20 && now() < connectDeadline; poll++) {
82
223
  const status = await request("status", undefined, grant, Math.min(15000, connectDeadline - now()));
83
224
  if (now() >= connectDeadline) break;
84
- if (!status.response.ok) throw failure(status.response.status, status.body);
225
+ if (!status.response.ok) throw failure(status.response.status, status.body, context, "grant");
85
226
  if (status.body.connector === "connected" && Number.isInteger(status.body.connections) && status.body.connections > 0) { connected = true; break; }
86
227
  await sleep(Math.min(3000, Math.max(0, connectDeadline - now())));
87
228
  }
88
229
  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);
230
+ const tested = await request("wake-test", { signing_secret: secret, preset }, grant);
231
+ if (!tested.response.ok) throw failure(tested.response.status, tested.body, context, "grant");
91
232
  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";
93
- throw new CliError(`wake test did not save settings (${result}); fix the receiver and run finish again for fresh approval`);
233
+ const { result, message } = wakeFailure(tested.body.result);
234
+ write(`wake_result=${result}`);
235
+ throw new CliError(message);
94
236
  }
237
+ write("wake_result=ok");
95
238
  write("Connector connected. Wake test passed and settings saved.");
96
239
  return { saved: true, connectorStarted: true };
97
240
  }
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)
@@ -119,6 +115,8 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
119
115
  wake fingerprint fingerprints (first 12 hex of sha256) of WAKE_* exported in this shell
120
116
  wake test send a test wake now and print the HTTP status
121
117
  wake unset remove wake secrets (fall back to polling)
118
+ handler prompts save autonomous, review-all or escalate mode and owner notices;
119
+ see docs/receiving-messages.md for setup and the every 6h webhook fallback
122
120
 
123
121
  Tunnel (local-only webhooks: OpenClaw gateway, Hermes, ...; needs Zero Trust/Access and a Cloudflare zone
124
122
  anywhere on the account: the A2A endpoint itself can be on workers.dev or a custom hostname)
@@ -281,7 +279,7 @@ Owner
281
279
  the MCP consent page when the issuer, client id, client secret, and allowlist are all set.
282
280
 
283
281
  Feedback
284
- feedback "message" [--kind bug|idea|other] [--contact x]
282
+ feedback "message" [--json] [--kind bug|idea|other] [--contact x]
285
283
  [--steps S] [--expected S] [--actual S] [--logs S] [--agent A]
286
284
  [--environment local|cloud VM|CI|cloud agent sandbox|other]
287
285
  [--connection webhook|proxy|polling] [--yes]
@@ -293,9 +291,9 @@ Feedback
293
291
  confidential information: secrets, tokens, owner or pairing
294
292
  tokens, private URLs or hostnames, message contents, or
295
293
  personal data. Prints that warning and asks before sending,
296
- unless --yes is set. Off a terminal, --yes is required.
294
+ unless --yes is set. Off a terminal, posting requires --yes.
297
295
  Posts when this A2A endpoint names a feedback URL.
298
- Otherwise prints a link to the bug
296
+ Otherwise prints Not filed and a link to the bug
299
297
  report form and does not open a browser. The CLI adds its
300
298
  version, the A2A endpoint Worker version when the A2A endpoint reports it,
301
299
  the OS, the Node version, the connection (webhook, proxy, or
@@ -303,7 +301,7 @@ Feedback
303
301
  that detection. Remove tokens from the message and from --logs.
304
302
 
305
303
  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:
304
+ Clients and how to load them:
307
305
  https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md#clients
308
306
  Config: ${C.CONFIG_FILE} (override dir with --config-dir DIR or A2A_CONFIG_DIR; env vars override file values)
309
307
  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 +347,7 @@ const deployOpts = {
349
347
  "workers-dev": B, "workers-dev-subdomain": S, "upstream-token-stdin": B, "no-upstream-token": B, "event-secret-stdin": B,
350
348
  };
351
349
  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 },
350
+ 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
351
  init: { ...deployOpts, ...tunnelOpts, tunnel: B }, deploy: deployOpts, wake: deployOpts, tunnel: tunnelOpts,
354
352
  inbox: { context: S, from: S, all: B, json: B },
355
353
  reply: { text: S, stdin: B, state: { type: "string", default: "completed" }, artifact: B, "artifact-name": { type: "string", default: "response" }, force: B },
@@ -363,10 +361,11 @@ const SPEC = {
363
361
  export: { sql: B },
364
362
  upstream: { json: B },
365
363
  connect: { alias: S, name: S, json: B, "no-wait": B, replace: B, force: B, "card-url": S },
364
+ // Internal hosted-service integration commands. Keep out of public help until the production flow is available.
366
365
  signup: { "control-url": S, json: B },
367
366
  login: { "control-url": S, json: B },
368
367
  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 },
368
+ feedback: { kind: S, contact: S, steps: S, expected: S, actual: S, logs: S, agent: S, environment: S, connection: S, yes: B, json: B },
370
369
  uninstall: { yes: B, "dry-run": B, dir: S, "worker-dir": S },
371
370
  };
372
371
  const STATES = ["completed", "input-required", "failed", "rejected", "working"];
@@ -410,7 +409,7 @@ export async function main(argv) {
410
409
  if (e && e.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION") {
411
410
  const flag = (String(e.message).match(/'([^']+)'/) || [])[1] || "that flag";
412
411
  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.`);
412
+ throw new CliError(`unknown flag ${flag}. Run \`${CLI} --help\` and see the ${name} section.`);
414
413
  }
415
414
  if (e && String(e.code || "").startsWith("ERR_PARSE_ARGS")) throw new CliError(String(e.message));
416
415
  throw e;
@@ -472,8 +471,8 @@ export async function main(argv) {
472
471
  throw new CliError(`unknown peer action ${sub} (announce|forward)`);
473
472
  }
474
473
  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") });
474
+ 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)");
475
+ return (p[1] === "create" ? createLink : finishLink)({ ...o, "control-url": o["control-url"] || C.get("CONTROL_URL") });
477
476
  }
478
477
  case "signup":
479
478
  case "login": {
@@ -495,21 +494,22 @@ export async function main(argv) {
495
494
  case "outbound": return cmd.outbound(need(p[0], "outbound <taskId>"));
496
495
  case "feedback": {
497
496
  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]`);
497
+ 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
498
  const kind = o.kind || "other";
500
499
  if (!fb.KINDS.includes(kind)) throw new CliError("--kind must be bug, idea, or other");
501
500
  if (o.connection && !fb.CONNECTIONS.includes(o.connection)) throw new CliError("--connection must be webhook, proxy, or polling");
502
501
  if (kind === "bug" && !String(o.steps || "").trim()) throw new CliError("a bug report needs reproduction steps (--steps)");
503
502
  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
503
  const result = await fb.runFeedback({
504
+ beforePost: () => fb.confirmPublicIssue(Boolean(o.yes), undefined, undefined, repo),
506
505
  message, kind, contact: o.contact || "", version: VERSION,
507
506
  steps: o.steps || "", expected: o.expected || "", actual: o.actual || "", logs: o.logs || "",
508
507
  agent: o.agent || "", environment: o.environment || "",
509
508
  connection: o.connection || "",
510
509
  baseUrl: C.get("A2A_BASE_URL"), ownerToken: C.get("A2A_OWNER_TOKEN"),
511
510
  });
512
- console.log(result.url);
511
+ if (!result.filed) console.error("Not filed: this A2A endpoint has no feedback inbox. Open this link to file it on GitHub:");
512
+ console.log(o.json ? JSON.stringify(result.filed ? { filed: true, issue: result.url } : result) : result.url);
513
513
  return;
514
514
  }
515
515
  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.29",
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.29";
@@ -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
+ });