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 +8 -0
- package/lib/deploy.mjs +33 -2
- package/lib/feedback.mjs +2 -1
- package/lib/link-finish.mjs +101 -20
- package/lib/main.mjs +20 -22
- package/lib/script.mjs +56 -0
- package/lib/workersdev.mjs +12 -5
- package/package.json +1 -1
- package/worker/src/index.ts +1 -1
- package/worker/src/version.ts +1 -1
- package/worker/src/wake.ts +7 -3
- package/worker/test/pairing.test.ts +37 -0
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"])
|
|
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"])
|
|
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 `
|
|
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, {
|
package/lib/link-finish.mjs
CHANGED
|
@@ -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 =
|
|
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
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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(
|
|
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" ||
|
|
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
|
|
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", "
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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}
|
|
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]
|
|
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.
|
|
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
|
+
}
|
package/lib/workersdev.mjs
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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) => {
|
|
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
package/worker/src/index.ts
CHANGED
|
@@ -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 } : {}),
|
package/worker/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Inbox build. Keep this equal to `version` in cli/package.json. */
|
|
2
|
-
export const VERSION = "0.8.
|
|
2
|
+
export const VERSION = "0.8.28";
|
package/worker/src/wake.ts
CHANGED
|
@@ -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
|
|
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
|
+
});
|