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 +8 -0
- package/lib/deploy.mjs +33 -2
- package/lib/feedback.mjs +2 -1
- package/lib/link-finish.mjs +164 -21
- package/lib/main.mjs +22 -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,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 =
|
|
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
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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(
|
|
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" ||
|
|
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
|
|
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
|
|
93
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -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
|
|
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
|
-
|
|
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}
|
|
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]
|
|
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.
|
|
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
|
+
}
|
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.29";
|
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
|
+
});
|