a2a-exposed 0.7.17 → 0.7.20
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/deploy.mjs +2 -0
- package/lib/feedback.mjs +362 -0
- package/lib/main.mjs +43 -0
- package/package.json +1 -1
- package/worker/cloudflare.config.ts +2 -0
- package/worker/deploy.env.example +3 -0
- package/worker/package.json +1 -0
- package/worker/scripts/stamp-version.mjs +15 -0
- package/worker/src/feedback.ts +309 -0
- package/worker/src/index.ts +39 -4
- package/worker/src/mcp.ts +22 -1
- package/worker/src/pairing.ts +1 -1
- package/worker/src/tenancy.ts +26 -0
- package/worker/src/theme.ts +1 -1
- package/worker/src/version.ts +2 -0
- package/worker/test/feedback.test.ts +190 -0
- package/worker/test/mcp.test.ts +45 -1
- package/worker/test/tenancy.test.ts +7 -1
package/lib/deploy.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { CLI, cardUrlProblem, cfErrorCode, die, fetchUpstreamCard, fingerprint,
|
|
|
9
9
|
import { owner, ownerTry } from "./commands.mjs";
|
|
10
10
|
import { readHidden } from "./pair.mjs";
|
|
11
11
|
import * as WD from "./workersdev.mjs";
|
|
12
|
+
import { printStar } from "./feedback.mjs";
|
|
12
13
|
|
|
13
14
|
const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
14
15
|
/** Each Claude Code routine fire is a new cloud session: a 2xx only means a session started. */
|
|
@@ -432,6 +433,7 @@ export async function init(o) {
|
|
|
432
433
|
reportSwitch(switched);
|
|
433
434
|
proxyNote(secrets);
|
|
434
435
|
console.error(`config saved to ${C.CONFIG_FILE} (chmod 600). Next: ${CLI} status (shows the next setup step)`);
|
|
436
|
+
printStar();
|
|
435
437
|
}
|
|
436
438
|
|
|
437
439
|
/** Proxy / expose mode reminder after a deploy (the upstream credentials are secrets: only their names are shown). */
|
package/lib/feedback.mjs
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
// Feedback: post to a hosted control plane when this inbox points at one, otherwise print a GitHub issue link.
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import readline from "node:readline/promises";
|
|
4
|
+
import { CliError } from "./a2a.mjs";
|
|
5
|
+
import * as C from "./config.mjs";
|
|
6
|
+
|
|
7
|
+
export const ISSUE_REPO = "telegraphic-dev/a2a-exposed";
|
|
8
|
+
export const STAR_LINE = "Like it? Star us: https://github.com/telegraphic-dev/a2a-exposed";
|
|
9
|
+
export const KINDS = ["bug", "idea", "other"];
|
|
10
|
+
export const CONNECTIONS = ["webhook", "proxy", "polling"];
|
|
11
|
+
|
|
12
|
+
const TOKEN_RE = /(?:a2aow_|s2a_|a2amcp_|a2amcpr_|a2aot_)[A-Za-z0-9_-]{20,}/g;
|
|
13
|
+
const SETUP_RE = /([?&])t=[A-Za-z0-9_-]{16,}/g;
|
|
14
|
+
const USERINFO_RE = /([a-z][a-z0-9+.-]*:\/\/)[^/?\s]*@/gi;
|
|
15
|
+
const BEARER_RE = /bearer\s+\S{8,200}/gi;
|
|
16
|
+
const PEM_RE = /-----BEGIN (?:[A-Z0-9]+ )?PRIVATE KEY-----[\s\S]*?(?:-----END (?:[A-Z0-9]+ )?PRIVATE KEY-----|$)/g;
|
|
17
|
+
const API_KEY_RE = /(?:(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|\bAKIA[0-9A-Z]{16}\b|\bsk_(?:live|test)_[A-Za-z0-9]{10,}|\brk_(?:live|test)_[A-Za-z0-9]{10,}|\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}|\bxox[baprs]-[A-Za-z0-9-]{10,}|\bAIza[0-9A-Za-z_-]{35}\b)/g;
|
|
18
|
+
|
|
19
|
+
/** Same shapes as the Worker's scrubSecretText, plus a Bearer value, a PEM private key, and common API-key prefixes. */
|
|
20
|
+
export function scrubFeedbackText(value) {
|
|
21
|
+
return String(value ?? "")
|
|
22
|
+
.replace(TOKEN_RE, "[redacted]")
|
|
23
|
+
.replace(SETUP_RE, "$1t=[redacted]")
|
|
24
|
+
.replace(USERINFO_RE, "$1[redacted]@")
|
|
25
|
+
.replace(BEARER_RE, "Bearer [redacted]")
|
|
26
|
+
.replace(PEM_RE, "[redacted]")
|
|
27
|
+
.replace(API_KEY_RE, "[redacted]");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
31
|
+
|
|
32
|
+
/** The repository that will receive the issue. The prefilled form is ISSUE_REPO. A hosted post uses the control plane's name. */
|
|
33
|
+
export function publicIssueWarning(repo = ISSUE_REPO) {
|
|
34
|
+
const name = typeof repo === "string" && REPO_RE.test(repo) && repo.length <= 100 && !repo.includes("..") ? repo : ISSUE_REPO;
|
|
35
|
+
return `This report is filed as a public GitHub issue on ${name}. Do not include confidential information: secrets, tokens, owner or pairing tokens, private URLs or hostnames, message contents, or personal data.`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const PUBLIC_ISSUE_WARNING = publicIssueWarning(ISSUE_REPO);
|
|
39
|
+
|
|
40
|
+
/** Print the public-issue warning. On a terminal, wait for y or yes unless `yes` is set. Off a terminal, `--yes` is required. */
|
|
41
|
+
export async function confirmPublicIssue(yes, input = process.stdin, output = process.stderr, repo = ISSUE_REPO) {
|
|
42
|
+
output.write(`${publicIssueWarning(repo)}\n`);
|
|
43
|
+
if (yes) return;
|
|
44
|
+
if (!input.isTTY) throw new CliError("feedback files a public GitHub issue; pass --yes to send it");
|
|
45
|
+
const rl = readline.createInterface({ input, output });
|
|
46
|
+
try {
|
|
47
|
+
const answer = (await rl.question("File this public issue? [y/N] ")).trim().toLowerCase();
|
|
48
|
+
if (answer !== "y" && answer !== "yes") throw new CliError("feedback not sent");
|
|
49
|
+
} finally {
|
|
50
|
+
rl.close();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const ENVIRONMENTS = ["local", "cloud VM", "CI", "cloud agent sandbox", "other"];
|
|
55
|
+
const FORM_FIELDS = [
|
|
56
|
+
["message", "What happened", true],
|
|
57
|
+
["version", "a2a-exposed version", true],
|
|
58
|
+
["connection", "Connection type", true],
|
|
59
|
+
["deployment", "Deployment", true],
|
|
60
|
+
["agent", "Agent", false],
|
|
61
|
+
["environment", "Environment", false],
|
|
62
|
+
["steps", "Reproduction steps", false],
|
|
63
|
+
["expected", "Expected", false],
|
|
64
|
+
["actual", "Actual", false],
|
|
65
|
+
["logs", "Logs", false],
|
|
66
|
+
["os", "OS", false],
|
|
67
|
+
["node", "Node", false],
|
|
68
|
+
["contact", "Contact", false],
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
function environmentValue(value) {
|
|
72
|
+
const text = String(value || "").trim();
|
|
73
|
+
if (ENVIRONMENTS.includes(text)) return text;
|
|
74
|
+
const aliases = { "local laptop": "local", "cloud vm/vps": "cloud VM" };
|
|
75
|
+
return aliases[text.toLowerCase()] || text;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function connectionText(value) {
|
|
79
|
+
return CONNECTIONS.includes(value) ? value : "unknown";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function deploymentText(value) {
|
|
83
|
+
if (value === "hosted") return "hosted";
|
|
84
|
+
if (value === "self-hosted" || value === "self-host") return "self-hosted";
|
|
85
|
+
return "unknown";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Prefill the bug report form. Query names are the form field ids. Dropdowns are set only when the value is one of their options. */
|
|
89
|
+
export function feedbackIssueUrl(input) {
|
|
90
|
+
const url = new URL(`https://github.com/${ISSUE_REPO}/issues/new`);
|
|
91
|
+
url.searchParams.set("template", "bug_report.yml");
|
|
92
|
+
if (input.title) url.searchParams.set("title", input.title);
|
|
93
|
+
let message = String(input.message || "").trim();
|
|
94
|
+
const environment = environmentValue(input.environment);
|
|
95
|
+
if (environment && !ENVIRONMENTS.includes(environment)) message = `${message}\n\nEnvironment: ${environment}`.trim();
|
|
96
|
+
const values = {
|
|
97
|
+
...input,
|
|
98
|
+
message,
|
|
99
|
+
environment: ENVIRONMENTS.includes(environment) ? environment : "",
|
|
100
|
+
connection: CONNECTIONS.includes(input.connection) ? input.connection : "",
|
|
101
|
+
deployment: input.deployment === "hosted" || input.deployment === "self-hosted" ? input.deployment : "",
|
|
102
|
+
};
|
|
103
|
+
for (const id of ["message", "version", "connection", "deployment", "agent", "environment", "steps", "expected", "actual", "logs", "os", "node", "contact"]) {
|
|
104
|
+
const value = String(values[id] || "").trim();
|
|
105
|
+
if (value && value !== "unknown") url.searchParams.set(id, value);
|
|
106
|
+
}
|
|
107
|
+
return url.href;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const LIMIT = {
|
|
111
|
+
message: 4000, steps: 4000, expected: 2000, actual: 2000, logs: 4000, contact: 200,
|
|
112
|
+
agent: 80, environment: 120, os: 80, node: 32, version: 80,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** Proxy when A2A_UPSTREAM_URL is set, webhook when WAKE_WEBHOOK_URL is set, otherwise polling. The URLs stay out of the result. */
|
|
116
|
+
export function localConnection() {
|
|
117
|
+
const upstream = String(C.get("A2A_UPSTREAM_URL") || "").trim().toLowerCase();
|
|
118
|
+
if (upstream && upstream !== "none" && upstream !== "off") return "proxy";
|
|
119
|
+
if (String(C.get("WAKE_WEBHOOK_URL") || "").trim()) return "webhook";
|
|
120
|
+
return "polling";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** OS and Node are filled in by the CLI. Connection and deployment are filled once the inbox answers, or from local config. */
|
|
124
|
+
export function localFacts() {
|
|
125
|
+
const raw = `${os.platform()} ${os.release()}`.replace(/[^\w .()+_-]/g, " ").replace(/\s+/g, " ").trim();
|
|
126
|
+
return { os: raw.slice(0, LIMIT.os), node: process.version.slice(0, LIMIT.node) };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function formText(input, key) {
|
|
130
|
+
if (key === "connection") return connectionText(input.connection);
|
|
131
|
+
if (key === "deployment") return deploymentText(input.deployment || input.mode);
|
|
132
|
+
if (key === "version") return scrubFeedbackText(input.version || "").trim() || "unknown";
|
|
133
|
+
if (key === "environment") return environmentValue(scrubFeedbackText(input.environment || "").trim());
|
|
134
|
+
return scrubFeedbackText(input[key] || "").trim();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function feedbackDocument(input) {
|
|
138
|
+
const message = scrubFeedbackText(input.message).trim();
|
|
139
|
+
const line = message.split(/\r?\n/).map((part) => part.trim()).find(Boolean) || "feedback";
|
|
140
|
+
const title = `[${input.kind}] ${line.length > 72 ? `${line.slice(0, 69)}...` : line}`;
|
|
141
|
+
const parts = [];
|
|
142
|
+
for (const [key, heading, always] of FORM_FIELDS) {
|
|
143
|
+
const text = formText(input, key);
|
|
144
|
+
if (!text && !always) continue;
|
|
145
|
+
parts.push(`### ${heading}\n\n${text || "unknown"}`);
|
|
146
|
+
}
|
|
147
|
+
return { title, body: parts.join("\n\n") };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function workerVersion(value) {
|
|
151
|
+
const text = String(value || "").trim();
|
|
152
|
+
return /^[A-Za-z0-9._+-]{1,32}$/.test(text) ? text : "";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function withWorker(cli, worker) {
|
|
156
|
+
if (!worker) return cli;
|
|
157
|
+
const mark = `(worker ${worker})`;
|
|
158
|
+
if (String(cli).includes(mark)) return String(cli).slice(0, LIMIT.version);
|
|
159
|
+
const combined = cli ? `${cli} ${mark}` : `worker ${worker}`;
|
|
160
|
+
return combined.slice(0, LIMIT.version);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** One dim line on a terminal. Piped stderr stays plain so tests can match the sentence. */
|
|
164
|
+
export function printStar() {
|
|
165
|
+
const line = process.stderr.isTTY ? `\x1b[2m${STAR_LINE}\x1b[0m` : STAR_LINE;
|
|
166
|
+
console.error(line);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function feedbackUrlOk(value) {
|
|
170
|
+
if (typeof value !== "string" || value.length > 300) return false;
|
|
171
|
+
try {
|
|
172
|
+
const url = new URL(value);
|
|
173
|
+
return url.protocol === "https:" && !url.username && !url.password && url.pathname === "/api/feedback" && !url.search && !url.hash;
|
|
174
|
+
} catch {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function githubIssue(value) {
|
|
180
|
+
if (typeof value !== "string" || value.length > 300) return "";
|
|
181
|
+
try {
|
|
182
|
+
const url = new URL(value);
|
|
183
|
+
if (url.protocol !== "https:" || url.hostname !== "github.com" || url.username || url.password) return "";
|
|
184
|
+
if (!/^\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/issues\/\d+$/.test(url.pathname)) return "";
|
|
185
|
+
return `${url.origin}${url.pathname}`;
|
|
186
|
+
} catch {
|
|
187
|
+
return "";
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function readJson(response) {
|
|
192
|
+
try { return await response.json(); } catch { return null; }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function namedRepo(value) {
|
|
196
|
+
const repo = typeof value === "string" ? value.trim() : "";
|
|
197
|
+
if (!REPO_RE.test(repo) || repo.length > 100 || repo.includes("..")) return "";
|
|
198
|
+
return repo;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Repository named in the warning before a report is sent.
|
|
203
|
+
* No hosted endpoint: the prefilled form on ISSUE_REPO.
|
|
204
|
+
* Hosted: GET /api/feedback must return `{ repo }`. A missing name does not guess ISSUE_REPO.
|
|
205
|
+
*/
|
|
206
|
+
export async function feedbackPublicRepo(input) {
|
|
207
|
+
const base = String(input.baseUrl || "").replace(/\/$/, "");
|
|
208
|
+
const token = String(input.ownerToken || "");
|
|
209
|
+
const call = input.fetchImpl ?? fetch;
|
|
210
|
+
if (!base || !token) return ISSUE_REPO;
|
|
211
|
+
let probe;
|
|
212
|
+
try {
|
|
213
|
+
probe = await call(`${base}/owner/feedback`, {
|
|
214
|
+
headers: { accept: "application/json", authorization: `Bearer ${token}` },
|
|
215
|
+
redirect: "error",
|
|
216
|
+
signal: AbortSignal.timeout(5000),
|
|
217
|
+
});
|
|
218
|
+
} catch {
|
|
219
|
+
probe = null;
|
|
220
|
+
}
|
|
221
|
+
const info = probe && probe.ok ? await readJson(probe) : null;
|
|
222
|
+
if (!info || info.hosted !== true || !feedbackUrlOk(info.url)) return ISSUE_REPO;
|
|
223
|
+
let named;
|
|
224
|
+
try {
|
|
225
|
+
named = await call(info.url, {
|
|
226
|
+
method: "GET",
|
|
227
|
+
headers: { accept: "application/json" },
|
|
228
|
+
redirect: "error",
|
|
229
|
+
signal: AbortSignal.timeout(5000),
|
|
230
|
+
});
|
|
231
|
+
} catch {
|
|
232
|
+
named = null;
|
|
233
|
+
}
|
|
234
|
+
const body = named && named.ok ? await readJson(named) : null;
|
|
235
|
+
const repo = namedRepo(body?.repo);
|
|
236
|
+
if (!repo) throw new CliError("the control plane did not name the public repository; feedback was not sent");
|
|
237
|
+
return repo;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Post when GET /owner/feedback says this inbox is hosted and names an https /api/feedback URL.
|
|
242
|
+
* A missing inbox, a probe failure, or a 404 prints the prefilled issue URL. 400, 429, and 5xx throw.
|
|
243
|
+
* @param {{ message: string, kind: string, contact?: string, version?: string, agent?: string, connection?: string, baseUrl?: string, ownerToken?: string, fetchImpl?: typeof fetch }} input
|
|
244
|
+
*/
|
|
245
|
+
export async function runFeedback(input) {
|
|
246
|
+
const kind = input.kind || "other";
|
|
247
|
+
const facts = localFacts();
|
|
248
|
+
const chosen = String(input.connection || "");
|
|
249
|
+
if (chosen && !CONNECTIONS.includes(chosen)) throw new CliError("--connection must be webhook, proxy, or polling");
|
|
250
|
+
const fields = {
|
|
251
|
+
message: String(input.message ?? ""),
|
|
252
|
+
kind,
|
|
253
|
+
contact: input.contact || "",
|
|
254
|
+
steps: input.steps || "",
|
|
255
|
+
expected: input.expected || "",
|
|
256
|
+
actual: input.actual || "",
|
|
257
|
+
logs: input.logs || "",
|
|
258
|
+
agent: input.agent || "",
|
|
259
|
+
environment: input.environment || "",
|
|
260
|
+
os: input.os || facts.os,
|
|
261
|
+
node: input.node || facts.node,
|
|
262
|
+
version: input.version || "",
|
|
263
|
+
connection: chosen,
|
|
264
|
+
deployment: "",
|
|
265
|
+
source: "cli",
|
|
266
|
+
};
|
|
267
|
+
for (const [key, max] of Object.entries(LIMIT)) {
|
|
268
|
+
if (String(fields[key] ?? "").length > max) throw new CliError(`${key} is too long`);
|
|
269
|
+
}
|
|
270
|
+
if (kind === "bug" && !String(fields.steps).trim()) throw new CliError("a bug report needs reproduction steps (--steps)");
|
|
271
|
+
const base = String(input.baseUrl || "").replace(/\/$/, "");
|
|
272
|
+
const token = String(input.ownerToken || "");
|
|
273
|
+
const call = input.fetchImpl ?? fetch;
|
|
274
|
+
let endpoint = "";
|
|
275
|
+
if (base && token) {
|
|
276
|
+
let probe;
|
|
277
|
+
try {
|
|
278
|
+
probe = await call(`${base}/owner/feedback`, {
|
|
279
|
+
headers: { accept: "application/json", authorization: `Bearer ${token}` },
|
|
280
|
+
redirect: "error",
|
|
281
|
+
signal: AbortSignal.timeout(5000),
|
|
282
|
+
});
|
|
283
|
+
} catch {
|
|
284
|
+
probe = null;
|
|
285
|
+
}
|
|
286
|
+
if (probe && probe.status !== 404) {
|
|
287
|
+
const info = probe.ok ? await readJson(probe) : null;
|
|
288
|
+
if (info?.hosted === true) {
|
|
289
|
+
fields.deployment = "hosted";
|
|
290
|
+
if (feedbackUrlOk(info.url)) endpoint = info.url;
|
|
291
|
+
} else if (info && info.hosted === false) fields.deployment = "self-hosted";
|
|
292
|
+
if (!fields.connection && CONNECTIONS.includes(info?.connection)) fields.connection = info.connection;
|
|
293
|
+
const worker = workerVersion(info?.version);
|
|
294
|
+
if (worker) fields.version = withWorker(fields.version, worker);
|
|
295
|
+
}
|
|
296
|
+
} else fields.deployment = "self-hosted";
|
|
297
|
+
if (!fields.connection) fields.connection = localConnection();
|
|
298
|
+
const doc = feedbackDocument(fields);
|
|
299
|
+
const fallback = () => ({
|
|
300
|
+
filed: false,
|
|
301
|
+
url: feedbackIssueUrl({
|
|
302
|
+
title: doc.title,
|
|
303
|
+
message: scrubFeedbackText(fields.message),
|
|
304
|
+
version: scrubFeedbackText(fields.version),
|
|
305
|
+
connection: fields.connection,
|
|
306
|
+
deployment: fields.deployment,
|
|
307
|
+
agent: scrubFeedbackText(fields.agent),
|
|
308
|
+
environment: environmentValue(scrubFeedbackText(fields.environment)),
|
|
309
|
+
steps: scrubFeedbackText(fields.steps),
|
|
310
|
+
expected: scrubFeedbackText(fields.expected),
|
|
311
|
+
actual: scrubFeedbackText(fields.actual),
|
|
312
|
+
logs: scrubFeedbackText(fields.logs),
|
|
313
|
+
os: scrubFeedbackText(fields.os),
|
|
314
|
+
node: scrubFeedbackText(fields.node),
|
|
315
|
+
contact: scrubFeedbackText(fields.contact),
|
|
316
|
+
}),
|
|
317
|
+
});
|
|
318
|
+
if (!endpoint) return fallback();
|
|
319
|
+
let response;
|
|
320
|
+
try {
|
|
321
|
+
response = await call(endpoint, {
|
|
322
|
+
method: "POST",
|
|
323
|
+
headers: {
|
|
324
|
+
accept: "application/json",
|
|
325
|
+
"content-type": "application/json",
|
|
326
|
+
authorization: `Bearer ${token}`,
|
|
327
|
+
},
|
|
328
|
+
body: JSON.stringify({
|
|
329
|
+
message: scrubFeedbackText(fields.message),
|
|
330
|
+
kind,
|
|
331
|
+
contact: scrubFeedbackText(fields.contact),
|
|
332
|
+
source: "cli",
|
|
333
|
+
agent: scrubFeedbackText(fields.agent),
|
|
334
|
+
version: scrubFeedbackText(fields.version),
|
|
335
|
+
steps: scrubFeedbackText(fields.steps),
|
|
336
|
+
expected: scrubFeedbackText(fields.expected),
|
|
337
|
+
actual: scrubFeedbackText(fields.actual),
|
|
338
|
+
logs: scrubFeedbackText(fields.logs),
|
|
339
|
+
environment: environmentValue(scrubFeedbackText(fields.environment)),
|
|
340
|
+
os: scrubFeedbackText(fields.os),
|
|
341
|
+
node: scrubFeedbackText(fields.node),
|
|
342
|
+
mode: fields.deployment,
|
|
343
|
+
deployment: fields.deployment,
|
|
344
|
+
connection: fields.connection,
|
|
345
|
+
}),
|
|
346
|
+
redirect: "error",
|
|
347
|
+
signal: AbortSignal.timeout(10000),
|
|
348
|
+
});
|
|
349
|
+
} catch (error) {
|
|
350
|
+
throw new CliError(`feedback request failed: ${error?.name === "TimeoutError" || error?.name === "AbortError" ? "timeout" : "unavailable"}`);
|
|
351
|
+
}
|
|
352
|
+
if (response.status === 404) return fallback();
|
|
353
|
+
const data = await readJson(response);
|
|
354
|
+
if (response.ok) {
|
|
355
|
+
const issue = githubIssue(data?.issue_url);
|
|
356
|
+
if (issue) return { filed: true, url: issue };
|
|
357
|
+
throw new CliError("feedback could not be filed");
|
|
358
|
+
}
|
|
359
|
+
if (response.status === 429) throw new CliError("too many feedback reports; try again later");
|
|
360
|
+
if (response.status === 400) throw new CliError("feedback rejected");
|
|
361
|
+
throw new CliError("feedback could not be filed");
|
|
362
|
+
}
|
package/lib/main.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import * as st from "./status.mjs";
|
|
|
9
9
|
import * as pair from "./pair.mjs";
|
|
10
10
|
import * as account from "./account.mjs";
|
|
11
11
|
import * as upd from "./update.mjs";
|
|
12
|
+
import * as fb from "./feedback.mjs";
|
|
12
13
|
|
|
13
14
|
const VERSION = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
14
15
|
|
|
@@ -204,6 +205,28 @@ Control plane account (only when that deployment's login is on)
|
|
|
204
205
|
return, and oauth_callback_failed when the provider sends the browser
|
|
205
206
|
back
|
|
206
207
|
|
|
208
|
+
Feedback
|
|
209
|
+
feedback "message" [--kind bug|idea|other] [--contact x]
|
|
210
|
+
[--steps S] [--expected S] [--actual S] [--logs S] [--agent A]
|
|
211
|
+
[--environment local|cloud VM|CI|cloud agent sandbox|other]
|
|
212
|
+
[--connection webhook|proxy|polling] [--yes]
|
|
213
|
+
report a bug or an idea. A bug needs --steps.
|
|
214
|
+
The report is a public GitHub issue. The warning names
|
|
215
|
+
the repository before it sends. A hosted inbox uses the
|
|
216
|
+
control plane's FEEDBACK_REPO. Otherwise the link opens
|
|
217
|
+
the form on telegraphic-dev/a2a-exposed. Do not include
|
|
218
|
+
confidential information: secrets, tokens, owner or pairing
|
|
219
|
+
tokens, private URLs or hostnames, message contents, or
|
|
220
|
+
personal data. Prints that warning and asks before sending,
|
|
221
|
+
unless --yes is set. Off a terminal, --yes is required.
|
|
222
|
+
Posts to the control plane when this inbox is hosted and that
|
|
223
|
+
plane accepts feedback. Otherwise prints a link to the bug
|
|
224
|
+
report form and does not open a browser. The CLI adds its
|
|
225
|
+
version, the inbox Worker version when the inbox reports it,
|
|
226
|
+
the OS, the Node version, the connection (webhook, proxy, or
|
|
227
|
+
polling), and hosted or self-hosted. --connection overrides
|
|
228
|
+
that detection. Remove tokens from the message and from --logs.
|
|
229
|
+
|
|
207
230
|
Presets: grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | generic
|
|
208
231
|
Config: ${C.CONFIG_FILE} (override dir with --config-dir DIR or A2A_CONFIG_DIR; env vars override file values)
|
|
209
232
|
Update notice: at most a daily background check of the npm registry; off with A2A_NO_UPDATE_CHECK=1 (or DO_NOT_TRACK=1)`;
|
|
@@ -230,6 +253,7 @@ const SPEC = {
|
|
|
230
253
|
signup: { "control-url": S, json: B },
|
|
231
254
|
login: { "control-url": S, json: B },
|
|
232
255
|
pair: { json: B, web: B, ttl: S, issuer: S, "client-id": S, subjects: S, methods: S, "secret-stdin": B },
|
|
256
|
+
feedback: { kind: S, contact: S, steps: S, expected: S, actual: S, logs: S, agent: S, environment: S, connection: S, yes: B },
|
|
233
257
|
};
|
|
234
258
|
const STATES = ["completed", "input-required", "failed", "rejected", "working"];
|
|
235
259
|
|
|
@@ -313,6 +337,25 @@ export async function main(argv) {
|
|
|
313
337
|
case "send": return cmd.send(o);
|
|
314
338
|
case "poll": return cmd.poll(need(p[0], "poll --to <peer> <taskId>"), o);
|
|
315
339
|
case "outbound": return cmd.outbound(need(p[0], "outbound <taskId>"));
|
|
340
|
+
case "feedback": {
|
|
341
|
+
const message = p.join(" ").trim();
|
|
342
|
+
need(message, `feedback "message" [--kind bug|idea|other] [--steps S] [--expected S] [--actual S] [--logs S] [--agent A] [--environment E] [--connection webhook|proxy|polling]`);
|
|
343
|
+
const kind = o.kind || "other";
|
|
344
|
+
if (!fb.KINDS.includes(kind)) throw new CliError("--kind must be bug, idea, or other");
|
|
345
|
+
if (o.connection && !fb.CONNECTIONS.includes(o.connection)) throw new CliError("--connection must be webhook, proxy, or polling");
|
|
346
|
+
if (kind === "bug" && !String(o.steps || "").trim()) throw new CliError("a bug report needs reproduction steps (--steps)");
|
|
347
|
+
const repo = await fb.feedbackPublicRepo({ baseUrl: C.get("A2A_BASE_URL"), ownerToken: C.get("A2A_OWNER_TOKEN") });
|
|
348
|
+
await fb.confirmPublicIssue(Boolean(o.yes), undefined, undefined, repo);
|
|
349
|
+
const result = await fb.runFeedback({
|
|
350
|
+
message, kind, contact: o.contact || "", version: VERSION,
|
|
351
|
+
steps: o.steps || "", expected: o.expected || "", actual: o.actual || "", logs: o.logs || "",
|
|
352
|
+
agent: o.agent || "", environment: o.environment || "",
|
|
353
|
+
connection: o.connection || "",
|
|
354
|
+
baseUrl: C.get("A2A_BASE_URL"), ownerToken: C.get("A2A_OWNER_TOKEN"),
|
|
355
|
+
});
|
|
356
|
+
console.log(result.url);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
316
359
|
default: throw new CliError(`unknown command ${name} (see --help)`);
|
|
317
360
|
}
|
|
318
361
|
}
|
package/package.json
CHANGED
|
@@ -49,6 +49,7 @@ const plain: Record<string, string> = {
|
|
|
49
49
|
// Hosted multi-tenant gates. All empty by default, and empty is omitted, so a self-host deploy is unchanged.
|
|
50
50
|
// TENANCY=host switches the entrypoint to src/hosted.ts and adds TENANT_DO + TENANT_DIRECTORY below.
|
|
51
51
|
// QUOTAS, USAGE_SINK, SIGNUP_URL and BRANDING are parsed and reserved.
|
|
52
|
+
// FEEDBACK_URL is an https origin. Only a hosted tenant posts feedback there. Unset on a self-hosted inbox.
|
|
52
53
|
// WAKE_TARGET_POLICY=public-https refuses a wake that is not public https. The socket opens to an address from that check, and TLS names the hostname (see wake-target.ts).
|
|
53
54
|
// APPROVAL_OIDC_* turn on OpenID Connect approval when issuer, client id, secret and allowlist are all set.
|
|
54
55
|
TENANCY: v("TENANCY"),
|
|
@@ -59,6 +60,7 @@ const plain: Record<string, string> = {
|
|
|
59
60
|
WAKE_TARGET_POLICY: v("WAKE_TARGET_POLICY"),
|
|
60
61
|
SIGNUP_URL: v("SIGNUP_URL"),
|
|
61
62
|
BRANDING: v("BRANDING"),
|
|
63
|
+
FEEDBACK_URL: v("FEEDBACK_URL"),
|
|
62
64
|
APPROVAL_OIDC_ISSUER: v("APPROVAL_OIDC_ISSUER"),
|
|
63
65
|
APPROVAL_OIDC_CLIENT_ID: v("APPROVAL_OIDC_CLIENT_ID"),
|
|
64
66
|
APPROVAL_OIDC_ALLOWED_SUBJECTS: v("APPROVAL_OIDC_ALLOWED_SUBJECTS"),
|
|
@@ -49,6 +49,9 @@ WAKE_PRESET=generic
|
|
|
49
49
|
# WAKE_TARGET_POLICY=public-https
|
|
50
50
|
# SIGNUP_URL=https://example.com
|
|
51
51
|
# BRANDING=hosted
|
|
52
|
+
# https origin, no path. Only a hosted tenant (TENANCY=host) posts feedback there.
|
|
53
|
+
# A self-hosted inbox ignores it and the CLI prints a GitHub issue link instead.
|
|
54
|
+
# FEEDBACK_URL=https://control.example.com
|
|
52
55
|
# OpenID Connect approval (optional). All four of issuer, client id, allowlist and the client secret
|
|
53
56
|
# must be set or the pages stay password-only. APPROVAL_METHODS=oidc hides the password once that
|
|
54
57
|
# config is complete; APPROVAL_METHODS=password keeps the password even if an issuer is set.
|
package/worker/package.json
CHANGED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Write worker/src/version.ts from cli/package.json. Publish runs `npm version` in cli/
|
|
2
|
+
// before the Worker tests and build, and prepack bundles this tree into the npm package.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const workerRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
8
|
+
const dest = process.argv[2] ? path.resolve(process.argv[2]) : path.join(workerRoot, "src", "version.ts");
|
|
9
|
+
const version = JSON.parse(fs.readFileSync(path.join(workerRoot, "..", "cli", "package.json"), "utf8")).version;
|
|
10
|
+
if (typeof version !== "string" || !/^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$/.test(version)) {
|
|
11
|
+
console.error(`stamp-version: cli/package.json version ${JSON.stringify(version)} is not a release version`);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
const body = `/** Inbox build. Keep this equal to \`version\` in cli/package.json. */\nexport const VERSION = ${JSON.stringify(version)};\n`;
|
|
15
|
+
if (!fs.existsSync(dest) || fs.readFileSync(dest, "utf8") !== body) fs.writeFileSync(dest, body);
|