@bongos/core 1.21.69 → 1.21.70

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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Bongos API reference
4
4
 
5
- > **Generated from the live route files** — the route file is authoritative. 520 endpoints across 93 route files.
5
+ > **Generated from the live route files** — the route file is authoritative. 522 endpoints across 93 route files.
6
6
  > Machine-readable spec: [`docs/api/openapi.json`](api/openapi.json) (OpenAPI 3.1). Rendered docs site: **`/docs`** (e.g. `cloudbongos.com/docs`).
7
7
 
8
8
  Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust-boundary-server-enforced-permissions.md)): `public` < `any-builder` < `metic+archon` < `archon`.
@@ -261,11 +261,12 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust
261
261
  | POST | `/api/bongos/done-when/:criterionId/unsatisfy` | `metic+archon` | `reason` | Auth: flip a criterion back to unsatisfied (rare; for corrections). |
262
262
  | GET | `/api/bongos/done-when/pending-review` | `metic+archon` | — | Auth (Metic+): the criterion review queue — every criterion auto-flagged "met — pending review" (≥1 linked task, all shipped, not yet con… |
263
263
 
264
- ## `feedback` (1)
264
+ ## `feedback` (2)
265
265
 
266
266
  | Method | Path | Rank | Body | Description |
267
267
  |---|---|---|---|---|
268
- | POST | `/api/bongos/feedback` | `any-builder` | `message`, `kind`, `context` | signed-in builder — reporting a problem with Bongos grants nothing here, and the hub files every report at 'backlog' behind a Metic+ nod. |
268
+ | POST | `/api/bongos/feedback/receipt` | `any-builder` | `sha256`, `kind` | signed-in builder — a receipt only says "this builder sent this text"; it grants nothing here, and the hub files every report behind a nod. |
269
+ | GET | `/api/bongos/feedback/receipt/:id` | `public` | — | the hub redeems a receipt with no session of its own. |
269
270
 
270
271
  ## `founding` (6)
271
272
 
@@ -793,7 +794,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust
793
794
  | GET | `/api/bongos/specialities/installed-skills` | `any-builder` | — | GET /specialities/installed-skills — every skill THIS instance has, each explained the way the adoption walkthrough explains it, so a spe… |
794
795
  | GET | `/api/bongos/specialities/offered` | `any-builder` | — | GET /specialities/offered — the suggestions for THIS builder. |
795
796
 
796
- ## `sso` (11)
797
+ ## `sso` (12)
797
798
 
798
799
  | Method | Path | Rank | Body | Description |
799
800
  |---|---|---|---|---|
@@ -803,6 +804,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust
803
804
  | POST | `/api/bongos/sso/device/poll` | `public` | `device_code` | POST /sso/device/poll — poll the hub device flow. |
804
805
  | POST | `/api/bongos/sso/device/start` | `public` | `origin` | POST /sso/device/start — begin CLI federation. |
805
806
  | POST | `/api/bongos/sso/feedback` | `public` | `client_id`, `client_secret`, `github_id`, `message`, `kind`, `context` | POST /sso/feedback — a federated project relays one builder's feedback about Bongos itself (task 1004462). |
807
+ | POST | `/api/bongos/sso/feedback/direct` | `public` | `origin`, `receipt`, `message`, `kind`, `context` | POST /sso/feedback/direct — `bongos feedback` straight from a builder's CLI, with a one-time receipt from their own project (task 1004591). |
806
808
  | POST | `/api/bongos/sso/invites/clear` | `public` | `client_id`, `client_secret`, `github_login` | authenticated by client_id + client_secret, not a builder rank. |
807
809
  | POST | `/api/bongos/sso/invites/notify` | `public` | `client_id`, `client_secret`, `github_login` | authenticated by client_id + client_secret, not a builder rank. |
808
810
  | POST | `/api/bongos/sso/membership/check-in` | `public` | `client_id`, `client_secret`, `github_id`, `github_login`, `display_name`, `avatar_url`, `membership_kind` | POST /sso/membership/check-in — a federated instance reports, on every sign-in, that a builder builds on its project (ADR 0141 §3). |
@@ -2970,5 +2970,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2970
2970
  landed since 1.21.67 with no explicit bump. run 37511231885. (task 1002620)
2971
2971
  1.21.69 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2972
2972
  landed since 1.21.68 with no explicit bump. run 37516251453. (task 1002620)
2973
+ 1.21.70 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2974
+ landed since 1.21.69 with no explicit bump. run 37524456209. (task 1002620)
2973
2975
  ---------------------------------------------------------------------------
2974
2976
  ```
@@ -21,6 +21,15 @@
21
21
  // an absent rank as untrusted, so even the owner's own feedback waits for the nod
22
22
  // rather than inheriting a hub rank the instance never vouched for.
23
23
  //
24
+ // TWO DOORS IN (task 1004591). The relay above needs the project linked to the hub,
25
+ // which many are not, so the CLI now sends STRAIGHT here (POST /sso/feedback/direct)
26
+ // with a one-time receipt from its own project (src/bongos/routes/feedback.js).
27
+ // The hub answers at once, then redeems the receipt at the project's public URL:
28
+ // the project names the signed-in sender and the hash of the text, and only a
29
+ // matching report is filed. A project on the hub's own domain is one we run, so
30
+ // its word is verified; any other domain is printed as "confirmed by <host>".
31
+ // The relay stays for projects on cores older than the receipt.
32
+ //
24
33
  // THE ATTRIBUTION IS THE HUB'S, NOT THE INSTANCE'S. Client credentials prove WHICH
25
34
  // PROJECT is calling and nothing about WHO sent it, so the sender must hold a
26
35
  // membership on that project carrying the hub's own sign-in witness
@@ -86,8 +95,11 @@ function feedbackTitle(message) {
86
95
  return `Feedback: ${title}`;
87
96
  }
88
97
 
89
- function feedbackBody({ message, client, account, githubId, context = {} }) {
90
- const who = account && account.github_login ? `@${account.github_login}` : `GitHub user ${githubId}`;
98
+ function feedbackBody({ message, client, account, githubId, context = {}, vouch = null }) {
99
+ // vouch (direct reports only): { hosted, host, login } — the receipt's word.
100
+ const claimed = vouch && typeof vouch.login === 'string' && /^[A-Za-z0-9-]{1,39}$/.test(vouch.login) ? vouch.login : null;
101
+ const who = account && account.github_login ? `@${account.github_login}`
102
+ : claimed ? `@${claimed} (GitHub user ${githubId})` : `GitHub user ${githubId}`;
91
103
  const project = client.name ? `${client.name} (${client.origin || client.client_id})` : (client.origin || client.client_id);
92
104
  const stamps = [
93
105
  ['Core', cleanStamp(context.core_version)],
@@ -97,10 +109,13 @@ function feedbackBody({ message, client, account, githubId, context = {} }) {
97
109
  // The hub's own attribution comes FIRST and the sender's words follow as a
98
110
  // quote, so text they type can never pose as the footer a reviewer trusts.
99
111
  const quoted = String(message).trim().split('\n').map((l) => `> ${l}`).join('\n');
112
+ const vouchLine = !vouch ? []
113
+ : [`- Sender: ${vouch.hosted ? 'verified (a project Cloud Bongos hosts)' : `confirmed by ${vouch.host} (self-hosted, not verified by us)`}`];
100
114
  return [
101
- 'Sent from a managed project with `bongos feedback`.',
115
+ vouch ? 'Sent with `bongos feedback`.' : 'Sent from a managed project with `bongos feedback`.',
102
116
  `- Project: ${project}`,
103
117
  `- Sent by: ${who}`,
118
+ ...vouchLine,
104
119
  ...(stamps ? [`- Running: ${stamps}`] : []),
105
120
  '',
106
121
  quoted,
@@ -110,12 +125,15 @@ function feedbackBody({ message, client, account, githubId, context = {} }) {
110
125
  // fileInstanceFeedback — file one relayed report. `deps.capture` is the
111
126
  // `ideas.capture` port; `deps.getAccount` and `deps.hubBuilderId` read the hub's
112
127
  // own rows for the sender. Returns the landing the CLI prints.
113
- async function fileInstanceFeedback({ client, githubId, message, kind, context }, deps) {
114
- const account = await deps.getAccount(githubId).catch(() => null);
115
- const capturedBy = await deps.hubBuilderId(githubId).catch(() => null);
128
+ async function fileInstanceFeedback({ client, githubId, message, kind, context, vouch = null }, deps) {
129
+ // A self-hosted project's word cannot borrow a hub account: its owner could
130
+ // name anyone's GitHub id, so the hub's login and builder row stay unread.
131
+ const trusted = !vouch || vouch.hosted;
132
+ const account = trusted ? await deps.getAccount(githubId).catch(() => null) : null;
133
+ const capturedBy = trusted ? await deps.hubBuilderId(githubId).catch(() => null) : null;
116
134
  const idea = await deps.capture({
117
135
  title: feedbackTitle(message),
118
- bodyMd: feedbackBody({ message, client, account, githubId, context }),
136
+ bodyMd: feedbackBody({ message, client, account, githubId, context, vouch }),
119
137
  kind: FEEDBACK_KINDS[kind],
120
138
  capturedBy,
121
139
  filerRank: null, // untrusted by design — see the header
@@ -129,7 +147,90 @@ async function fileInstanceFeedback({ client, githubId, message, kind, context }
129
147
  : { landed: 'inbox', idea_id: String(idea.id) };
130
148
  }
131
149
 
150
+ // ── the direct door (task 1004591) ───────────────────────────────────────────
151
+
152
+ const RECEIPT_TIMEOUT_MS = 8000;
153
+
154
+ function sha256Hex(text) {
155
+ return require('node:crypto').createHash('sha256').update(String(text), 'utf8').digest('hex');
156
+ }
157
+
158
+ // projectOrigin — the caller-named project, accepted only as a bare https origin
159
+ // on a DNS name (no IP literal, no port, no path, no userinfo). Null otherwise.
160
+ function projectOrigin(raw) {
161
+ let u;
162
+ try { u = new URL(String(raw || '')); } catch { return null; }
163
+ if (u.protocol !== 'https:' || u.port || u.username || u.password) return null;
164
+ if (u.pathname !== '/' && u.pathname !== '') return null;
165
+ if (u.search || u.hash) return null;
166
+ const host = u.hostname.toLowerCase();
167
+ if (!host.includes('.') || require('node:net').isIP(host.replace(/^\[|\]$/g, ''))) return null;
168
+ if (/(^|\.)(localhost|local|internal|test|invalid)$/.test(host)) return null;
169
+ return { origin: `https://${host}`, host };
170
+ }
171
+
172
+ // isHostedBy — a project on the hub's own domain (or the hub itself) is one we run.
173
+ function isHostedBy(host, hubHost) {
174
+ const h = String(hubHost || '').toLowerCase();
175
+ return !!h && (host === h || host.endsWith(`.${h}`));
176
+ }
177
+
178
+ // verifyDirectReport — redeem the receipt at the project and check it matches.
179
+ // Resolves { ok: true, receipt } or { ok: false, reason }; never throws. The
180
+ // address is attacker-named, so the host must resolve to a public address
181
+ // (deps.assertPublicHost) and a redirect is an error, never followed.
182
+ async function verifyDirectReport({ origin, receipt, message, kind }, deps) {
183
+ const target = projectOrigin(origin);
184
+ if (!target) return { ok: false, reason: 'bad_origin' };
185
+ if (!/^[A-Za-z0-9_-]{16,64}$/.test(String(receipt || ''))) return { ok: false, reason: 'bad_receipt' };
186
+ try {
187
+ await deps.assertPublicHost(target.host);
188
+ } catch {
189
+ return { ok: false, reason: 'private_host' };
190
+ }
191
+ let body;
192
+ try {
193
+ const res = await (deps.fetchImpl || fetch)(`${target.origin}/api/bongos/feedback/receipt/${receipt}`, {
194
+ headers: { accept: 'application/json' },
195
+ redirect: 'error',
196
+ signal: AbortSignal.timeout(RECEIPT_TIMEOUT_MS),
197
+ });
198
+ if (res.status !== 200) return { ok: false, reason: `receipt_http_${res.status}` };
199
+ body = await res.json();
200
+ } catch {
201
+ return { ok: false, reason: 'receipt_unreachable' };
202
+ }
203
+ if (!body || typeof body !== 'object') return { ok: false, reason: 'receipt_malformed' };
204
+ if (!/^\d{1,20}$/.test(String(body.github_id || ''))) return { ok: false, reason: 'receipt_malformed' };
205
+ if (body.sha256 !== sha256Hex(message)) return { ok: false, reason: 'text_mismatch' };
206
+ if ((body.kind || 'bug') !== kind) return { ok: false, reason: 'kind_mismatch' };
207
+ return { ok: true, target, receipt: body };
208
+ }
209
+
210
+ // fileDirectReport — the whole after-the-202 half: verify, then file. Returns the
211
+ // landing, or { dropped: reason } for a report the project did not vouch for.
212
+ async function fileDirectReport({ origin, receipt, message, kind, context = {}, hubHost }, deps) {
213
+ const v = await verifyDirectReport({ origin, receipt, message, kind }, deps);
214
+ if (!v.ok) return { dropped: v.reason };
215
+ const githubId = String(v.receipt.github_id);
216
+ const hosted = isHostedBy(v.target.host, hubHost);
217
+ return fileInstanceFeedback({
218
+ client: { name: v.target.host, origin: v.target.origin, client_id: v.target.host },
219
+ githubId,
220
+ message,
221
+ kind,
222
+ // The project's own core version beats whatever the CLI claimed.
223
+ context: { ...context, core_version: v.receipt.core_version || null },
224
+ vouch: { hosted, host: v.target.host, login: v.receipt.github_login },
225
+ }, deps);
226
+ }
227
+
132
228
  module.exports = {
229
+ sha256Hex,
230
+ projectOrigin,
231
+ isHostedBy,
232
+ verifyDirectReport,
233
+ fileDirectReport,
133
234
  FEEDBACK_KINDS,
134
235
  MESSAGE_MAX,
135
236
  PER_SENDER_LIMIT,
@@ -30,9 +30,25 @@ const applicationEcho = require('../application-echo');
30
30
  // The guild label the echo carries (task 1002303, ADR 0365 D5).
31
31
  const guildEngagements = require('../guild-engagements');
32
32
  const instanceFeedback = require('../instance-feedback');
33
+ const projectStats = require('../project-stats');
33
34
 
34
35
  // One limiter per hub process for POST /sso/feedback (task 1004462).
35
36
  const feedbackLimiter = instanceFeedback.makeFeedbackLimiter();
37
+ // …and two for POST /sso/feedback/direct (task 1004591), which carries no
38
+ // credential: one keyed by the caller's address ALONE, so naming a fresh host on
39
+ // every request buys nothing, and one by the named project and address.
40
+ const directIpLimiter = instanceFeedback.makeFeedbackLimiter();
41
+ const directFeedbackLimiter = instanceFeedback.makeFeedbackLimiter();
42
+
43
+ // The hub's own host: a direct report from it or a subdomain of it comes from a
44
+ // project we run. Best-effort — no host means nothing counts as hosted. Read once.
45
+ let _hubHost = null;
46
+ function hubHost() {
47
+ if (_hubHost === null) {
48
+ try { _hubHost = new URL(api.branding().domains.publicOrigin).hostname.toLowerCase(); } catch { _hubHost = ''; }
49
+ }
50
+ return _hubHost;
51
+ }
36
52
 
37
53
  // Per-IP cap for POST /sso/applicant-profile — DEFENCE IN DEPTH behind the echo
38
54
  // gate, not the gate itself (task 1002972 grade advisory). The echo already
@@ -781,5 +797,60 @@ module.exports = function ssoRoutes() {
781
797
  }
782
798
  });
783
799
 
800
+ // POST /sso/feedback/direct — `bongos feedback` straight from a builder's CLI,
801
+ // with a one-time receipt from their own project (task 1004591). Answers 202 at
802
+ // once so the builder never waits; the receipt is redeemed AFTER, and a report
803
+ // the project does not vouch for is dropped (logged, never filed). Why this
804
+ // proves the sender, and what "hosted" earns: ../instance-feedback.js.
805
+ //
806
+ // rank: public — no session on the hub; the proof is the project's receipt.
807
+ // Capped per caller address (10/h whatever host it names), then per named
808
+ // project (30/h), BEFORE anything is fetched.
809
+ router.post('/sso/feedback/direct', (req, res) => {
810
+ if (validateOrRespond(req, res, {
811
+ origin: { required: true, type: 'string', maxLength: 200 },
812
+ receipt: { required: true, type: 'string', maxLength: 64 },
813
+ message: { required: true, type: 'string', maxLength: instanceFeedback.MESSAGE_MAX },
814
+ kind: { type: 'string', enum: Object.keys(instanceFeedback.FEEDBACK_KINDS) },
815
+ context: {},
816
+ })) return;
817
+ const b = req.body || {};
818
+ const target = instanceFeedback.projectOrigin(b.origin);
819
+ if (!target) return res.fail('bad_origin', 400);
820
+ const message = String(b.message).trim();
821
+ if (!message) return res.fail('message_required', 400);
822
+ // The address's own bucket first (both of its tiers keyed by the address, so
823
+ // no bucket is shared between callers); only an address still under its cap
824
+ // may spend from the named project's bucket — a refused caller costs a real
825
+ // project nothing.
826
+ const ip = String(req.ip || 'unknown');
827
+ let allowed = directIpLimiter.take(ip, 'direct');
828
+ if (allowed.ok) allowed = directFeedbackLimiter.take(target.host, ip);
829
+ if (!allowed.ok) {
830
+ res.set('Retry-After', String(allowed.retryAfter));
831
+ return res.fail('rate_limited', 429, { scope: `feedback-${allowed.scope}`, retry_after_seconds: allowed.retryAfter });
832
+ }
833
+ res.status(202).json({ ok: true, accepted: true });
834
+ const ctx = b.context && typeof b.context === 'object' ? b.context : {};
835
+ instanceFeedback.fileDirectReport({
836
+ origin: target.origin,
837
+ receipt: b.receipt,
838
+ message: b.message,
839
+ kind: b.kind || 'bug',
840
+ context: { cli_version: ctx.cli_version, os: ctx.os },
841
+ hubHost: hubHost(),
842
+ }, {
843
+ assertPublicHost: (host) => projectStats.assertPublicHost(host),
844
+ capture: (...a) => api.resolve('ideas.capture')(...a),
845
+ getAccount: (id) => pi.getAccount(id, { pool }),
846
+ hubBuilderId: async (id) => {
847
+ const { rows } = await pool.query('SELECT id FROM builders WHERE github_id = $1 LIMIT 1', [id]);
848
+ return rows[0] ? rows[0].id : null;
849
+ },
850
+ }).then((r) => {
851
+ if (r && r.dropped) log.warn(`[platform-identity] /sso/feedback/direct dropped from ${target.host}: ${r.dropped}`);
852
+ }, (err) => log.error('[platform-identity] /sso/feedback/direct', err));
853
+ });
854
+
784
855
  return router;
785
856
  };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.69",
3
+ "version": "1.21.70",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.69",
9
+ "version": "1.21.70",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.69",
3
+ "version": "1.21.70",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -9194,5 +9194,10 @@
9194
9194
  "id": "1004277",
9195
9195
  "text": "The projects you build on, with their Leave buttons, now live on your account page. My projects links there."
9196
9196
  }
9197
+ ],
9198
+ "1.21.70": [
9199
+ {
9200
+ "text": "Reporting a problem with Bongos now works from every project, including ones like Will's that could not send feedback before. It takes a second, needs no extra sign-in, and the Bongos team can see who really sent each report."
9201
+ }
9197
9202
  ]
9198
9203
  }
@@ -1,40 +1,58 @@
1
1
  #!/usr/bin/env node
2
- // Send feedback about Bongos itself to the Cloud Bongos maintainers (task 1004462).
2
+ // Send feedback about Bongos itself to the Cloud Bongos maintainers (tasks 1004462, 1004591).
3
3
  //
4
4
  // Usage:
5
5
  // bongos feedback "the claim command hung for two minutes"
6
6
  // bongos feedback --idea "let me filter the task list by goal"
7
- // bongos feedback --message-file notes.md
8
- // [--json]
7
+ // bongos feedback --message-file notes.md (or --message-file - for stdin)
8
+ // [--wait] send in the foreground and print the outcome (debugging)
9
9
  //
10
- // Not the /feedback skill, which loads a locally recorded walkthrough into a
11
- // session. This SENDS: it posts to the builder's OWN project (POST /feedback,
12
- // src/bongos/routes/feedback.js), which relays it to the hub. A bug lands on the
13
- // hub as a 'backlog' task in the maintenance goal awaiting a maintainer's nod; an
14
- // idea lands in the hub's idea inbox. Only the typed text and version stamps
15
- // travel — never code, a transcript or file contents.
10
+ // Straight to the hub, never through the project: a project with no hub link can
11
+ // still send. The builder's OWN project vouches for who sent it — the CLI asks it
12
+ // for a one-time receipt (src/bongos/routes/feedback.js) and the hub redeems that
13
+ // receipt before filing (modules/platform-identity/instance-feedback.js). So a
14
+ // signed-in builder does nothing extra.
15
+ //
16
+ // Fast and quiet by design (the owner, 2026-10-06): the command prints one line
17
+ // and returns at once; a detached background process does the network. No task
18
+ // number comes back. Only the typed text (secret-scrubbed) and version stamps travel.
16
19
 
17
20
  'use strict';
18
21
 
22
+ const fs = require('node:fs');
19
23
  const os = require('node:os');
20
- const { cliClient, requireSession, argText, hasFlag, errCode, errDetails, cliExit } = require('./cli-lib');
24
+ const path = require('node:path');
25
+ const crypto = require('node:crypto');
26
+ const { spawn } = require('node:child_process');
27
+ const { cliClient, requireSession, loadSessionSync, argText, hasFlag, errCode, cliExit } = require('./cli-lib');
28
+ const ic = require('../../src/instance-config');
21
29
 
22
30
  const USAGE = [
23
31
  'usage:',
24
32
  ' bongos feedback "what went wrong" report a problem with Bongos',
25
33
  ' bongos feedback --idea "what you wish for" suggest an improvement',
26
- ' bongos feedback --message-file notes.md send a longer note from a file',
34
+ ' bongos feedback --message-file notes.md send a longer note from a file (- for stdin)',
27
35
  '',
28
36
  'Goes to the Cloud Bongos maintainers. Only the text you type and the Bongos',
29
37
  'versions you run are sent — no code, chat or files from your project.',
30
38
  ].join('\n');
31
39
 
32
- // The CLI release, as `bongos version` reports it: config/bongos-cli.json where
33
- // the core checkout carries it (an instance does not — task 1824), else the
34
- // package version. Read from disk, never required, so a vanilla core still runs.
40
+ const SENT_LINE = 'Sent to the Bongos team.';
41
+ const REPEAT_WINDOW_MS = 6 * 60 * 60 * 1000;
42
+
43
+ // Plain lines for the refusals the builder can see (everything else happens in
44
+ // the background and is written to the outcome file, never to the terminal).
45
+ const REFUSALS = {
46
+ message_required: 'Nothing to send — add your feedback after the command, in quotes.',
47
+ too_long: 'That is too long to send — keep it under 4,000 characters, or split it into two.',
48
+ local_only: 'This project runs only on your computer, so Cloud Bongos cannot confirm who sent the report. Nothing was sent.',
49
+ no_scrubber: 'Feedback was not sent: the secret filter that checks every report is missing from this install.',
50
+ };
51
+
52
+ function stateDir() { return path.join(os.homedir(), '.config', ic.FALLBACK_DIR || 'cloudbongos'); }
53
+
54
+ // The CLI release, as `bongos version` reports it. Read from disk, never required.
35
55
  function cliVersion() {
36
- const fs = require('node:fs');
37
- const path = require('node:path');
38
56
  for (const rel of ['../../config/bongos-cli.json', '../../package.json']) {
39
57
  try {
40
58
  const f = path.join(__dirname, rel);
@@ -44,17 +62,19 @@ function cliVersion() {
44
62
  return null;
45
63
  }
46
64
 
47
- // The message: --message / --message-file, else every positional word joined.
48
- function readMessage(args) {
49
- if (args.includes('--message') || args.includes('--message-file')) return argText('--message', args);
50
- return args.filter((a) => !a.startsWith('--')).join(' ');
65
+ // The message: --message / --message-file (incl. - for stdin), else every
66
+ // positional word joined, else whatever was piped in.
67
+ function readMessage(args, io = {}) {
68
+ if (args.includes('--message') || args.includes('--message-file')) return argText('--message', args, io).value || '';
69
+ const words = args.filter((a) => !a.startsWith('--')).join(' ');
70
+ if (words) return words;
71
+ const stdinIsPiped = io.stdinIsPiped !== undefined ? io.stdinIsPiped : !process.stdin.isTTY;
72
+ if (!stdinIsPiped) return '';
73
+ try { return (io.readStdin || (() => fs.readFileSync(0, 'utf8')))(); } catch { return ''; }
51
74
  }
52
75
 
53
76
  // Every report goes through the transcript-grade secret scrubber before it leaves
54
- // this machine (task 1004467). An assistant may file unasked when a builder vents,
55
- // so nobody reviews the text first — the scrubber is the check that does not
56
- // depend on the assistant's judgment. FAIL CLOSED: no scrubber, nothing is sent.
57
- // It also strips the builder's environment secrets by value, whatever their shape.
77
+ // this machine (task 1004467). FAIL CLOSED: no scrubber, nothing is sent.
58
78
  function scrubReport(message, env = process.env) {
59
79
  let scrub;
60
80
  try { scrub = require('../../modules/security/secret-scrub.js'); } catch { return null; }
@@ -63,69 +83,122 @@ function scrubReport(message, env = process.env) {
63
83
  return { text, removed };
64
84
  }
65
85
 
66
- // Plain-language lines for each refusal the route can give.
67
- const REFUSALS = {
68
- message_required: 'Nothing to send — add your feedback after the command, in quotes.',
69
- validation_failed: 'That could not be sent as written — keep it under 4,000 characters, or split it into two.',
70
- not_hub_connected: 'This project is not connected to Cloud Bongos, so feedback cannot be relayed from here.\n'
71
- + 'Report it to the Bongos maintainers on the Bongos core repository instead.',
72
- membership_required: 'Cloud Bongos does not yet know you as a member of this project.\n'
73
- + 'Sign in to this project once through Cloud Bongos in a browser, then try again.',
74
- rate_limited: 'Too much feedback from you or this project in the last hour. Try again later.',
75
- feedback_unavailable: 'Cloud Bongos is not taking bug reports right now (no version is being built). Try again later.',
76
- hub_unreachable: 'Could not reach Cloud Bongos. Check your connection and try again.',
77
- };
86
+ // A project the hub cannot reach can never vouch for a report — say so up front
87
+ // rather than send something that is certain to be dropped.
88
+ function isLocalBase(base) {
89
+ let host;
90
+ try { host = new URL(base).hostname.toLowerCase(); } catch { return true; }
91
+ if (require('node:net').isIP(host.replace(/^\[|\]$/g, ''))) return true;
92
+ return !host.includes('.') || /(^|\.)(localhost|local|internal|test|invalid)$/.test(host);
93
+ }
94
+
95
+ // Repeat guard: the same report (same first line) inside REPEAT_WINDOW_MS is not
96
+ // sent twice, so an assistant filing on every rant cannot spam the maintainers.
97
+ function repeatKey(message) {
98
+ const first = String(message).split('\n').map((l) => l.trim().toLowerCase()).find(Boolean) || '';
99
+ return crypto.createHash('sha256').update(first).digest('hex').slice(0, 16);
100
+ }
101
+ function checkAndRecordRepeat(message, { file = path.join(stateDir(), 'feedback-sent.json'), now = Date.now() } = {}) {
102
+ let seen = {};
103
+ try { seen = JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch { /* first report */ }
104
+ for (const [k, t] of Object.entries(seen)) if (!(now - t < REPEAT_WINDOW_MS)) delete seen[k];
105
+ const key = repeatKey(message);
106
+ if (Object.prototype.hasOwnProperty.call(seen, key)) return true;
107
+ seen[key] = now;
108
+ try {
109
+ fs.mkdirSync(path.dirname(file), { recursive: true });
110
+ fs.writeFileSync(file, JSON.stringify(seen), { mode: 0o600 });
111
+ } catch { /* a guard that cannot write still lets the report through */ }
112
+ return false;
113
+ }
114
+
115
+ // deliver — the network half: receipt from the project, then the report to the
116
+ // hub. Returns an outcome object; never prints. Runs in the background child.
117
+ async function deliver({ message, kind }, deps = {}) {
118
+ const api = deps.api || await cliClient();
119
+ const base = deps.base || ic.resolveApiBase({ session: loadSessionSync() });
120
+ const doFetch = deps.fetchImpl || fetch;
121
+ const sha256 = crypto.createHash('sha256').update(message, 'utf8').digest('hex');
122
+ const r = await api.request('POST', '/feedback/receipt', { body: { sha256, kind } });
123
+ if (!r.ok) return { ok: false, step: 'receipt', code: errCode(r.data) || `http_${r.status}` };
124
+ const origin = new URL(base).origin;
125
+ const hub = String((r.data && r.data.hub) || origin).replace(/\/+$/, '');
126
+ const res = await doFetch(`${hub}/api/bongos/sso/feedback/direct`, {
127
+ method: 'POST',
128
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
129
+ body: JSON.stringify({
130
+ origin, receipt: r.data.receipt, message, kind,
131
+ context: { cli_version: cliVersion(), os: `${os.platform()} ${os.release()}` },
132
+ }),
133
+ signal: AbortSignal.timeout(15000),
134
+ });
135
+ if (res.status === 202) return { ok: true, hub };
136
+ let body = null;
137
+ try { body = await res.json(); } catch { /* non-JSON */ }
138
+ return { ok: false, step: 'hub', code: errCode(body) || `http_${res.status}` };
139
+ }
140
+
141
+ // The background child: read the queued report, deliver it, record the outcome
142
+ // where a curious builder (or a maintainer helping them) can look.
143
+ async function runDeliverChild(file) {
144
+ let job;
145
+ try { job = JSON.parse(fs.readFileSync(file, 'utf8')); } finally { fs.rmSync(path.dirname(file), { recursive: true, force: true }); }
146
+ let outcome;
147
+ try { outcome = await deliver(job); } catch (e) { outcome = { ok: false, step: 'network', code: String(e && e.message || e).slice(0, 200) }; }
148
+ try {
149
+ fs.writeFileSync(path.join(stateDir(), 'feedback-last.json'), JSON.stringify({ at: new Date().toISOString(), ...outcome }), { mode: 0o600 });
150
+ } catch { /* nowhere to record — the report itself is what mattered */ }
151
+ return outcome.ok ? 0 : 1;
152
+ }
153
+
154
+ // Hand the job to a detached child and return. The job travels in a 0600 temp
155
+ // file, never argv (visible in ps) or env.
156
+ function spawnDelivery(job, { spawnImpl = spawn } = {}) {
157
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bongos-feedback-'));
158
+ const file = path.join(dir, 'job.json');
159
+ fs.writeFileSync(file, JSON.stringify(job), { mode: 0o600 });
160
+ const child = spawnImpl(process.execPath, [__filename, '--deliver', file], { detached: true, stdio: 'ignore' });
161
+ if (child && child.unref) child.unref();
162
+ return file;
163
+ }
78
164
 
79
- async function main(argv = process.argv.slice(2)) {
165
+ async function main(argv = process.argv.slice(2), deps = {}) {
80
166
  if (hasFlag('--help', argv) || hasFlag('-h', argv)) { console.log(USAGE); return 0; }
81
- const json = hasFlag('--json', argv);
82
167
  const kind = hasFlag('--idea', argv) ? 'idea' : 'bug';
83
- const message = String(readMessage(argv) || '').trim();
84
- if (!message) { console.error(USAGE); return 2; }
168
+ const message = String(readMessage(argv, deps.io) || '').trim();
169
+ if (!message) { console.error(REFUSALS.message_required); return 2; }
170
+ if (message.length > 4000) { console.error(REFUSALS.too_long); return 2; }
85
171
 
86
172
  const scrubbed = scrubReport(message);
87
- if (!scrubbed) {
88
- console.error('Feedback was not sent: the secret filter that checks every report is missing from this install.');
89
- return 1;
90
- }
173
+ if (!scrubbed) { console.error(REFUSALS.no_scrubber); return 1; }
91
174
 
92
- await requireSession();
93
- const api = await cliClient();
94
- const res = await api.request('POST', '/feedback', {
95
- body: { message: scrubbed.text, kind, context: { cli_version: cliVersion(), os: `${os.platform()} ${os.release()}` } },
96
- });
97
- if (scrubbed.removed > 0 && !json) {
98
- console.log(`Removed ${scrubbed.removed} secret-looking value(s) from the report before sending.`);
99
- }
175
+ const session = await (deps.requireSession || requireSession)();
176
+ const base = deps.base || ic.resolveApiBase({ session });
177
+ if (isLocalBase(base)) { console.error(REFUSALS.local_only); return 1; }
100
178
 
101
- if (json) { console.log(JSON.stringify(res.data, null, 2)); return res.ok ? 0 : 1; }
102
- if (!res.ok) {
103
- const code = errCode(res.data);
104
- const details = errDetails(res.data) || {};
105
- let line = REFUSALS[code] || `Feedback was not sent (${code || `HTTP ${res.status}`}).`;
106
- if (code === 'rate_limited' && details.retry_after_seconds) {
107
- line += ` (in about ${Math.ceil(details.retry_after_seconds / 60)} min)`;
108
- }
109
- console.error(line);
110
- return 1;
179
+ const job = { message: scrubbed.text, kind };
180
+ if ((deps.checkRepeat || checkAndRecordRepeat)(job.message)) {
181
+ console.log('Already sent — the Bongos team has this one.');
182
+ return 0;
111
183
  }
112
- const d = res.data || {};
113
- if (d.landed === 'task') {
114
- console.log(`Thanks — sent to the Cloud Bongos maintainers as task ${d.task_id}.`);
115
- console.log('It waits for a maintainer to approve it before anyone works on it.');
116
- if (d.hub_origin) console.log(`${String(d.hub_origin).replace(/\/+$/, '')}/builders#/task/${d.task_id}`);
117
- } else {
118
- console.log(`Thanks — your idea is in the Cloud Bongos idea inbox (idea ${d.idea_id}) for the maintainers to review.`);
184
+ if (hasFlag('--wait', argv)) {
185
+ const outcome = await (deps.deliver || deliver)(job);
186
+ console.log(outcome.ok ? SENT_LINE : `Not sent (${outcome.step}: ${outcome.code}).`);
187
+ return outcome.ok ? 0 : 1;
119
188
  }
189
+ (deps.spawnDelivery || spawnDelivery)(job);
190
+ console.log(SENT_LINE);
120
191
  return 0;
121
192
  }
122
193
 
123
194
  if (require.main === module) {
195
+ const argv = process.argv.slice(2);
196
+ const run = argv[0] === '--deliver' ? runDeliverChild(argv[1]) : main(argv);
124
197
  // cliExit, never process.exit, after a request (task 1002734 — the Windows abort).
125
- main().then((code) => cliExit(code), (err) => {
198
+ run.then((code) => cliExit(code), (err) => {
126
199
  console.error(`Feedback was not sent: ${err && err.message ? err.message : err}`);
127
200
  cliExit(1);
128
201
  });
129
202
  }
130
203
 
131
- module.exports = { main, readMessage, REFUSALS, scrubReport };
204
+ module.exports = { main, deliver, readMessage, REFUSALS, scrubReport, isLocalBase, repeatKey, checkAndRecordRepeat, SENT_LINE };
@@ -110,6 +110,9 @@ const EXEMPT = [
110
110
  /^\/downloads\//,
111
111
  { re: new RegExp(`^${API}/access-requests$`), methods: ['POST'] },
112
112
  { re: new RegExp(`^${API}/access-requests/status$`), methods: ['GET'] },
113
+ // The hub redeeming a `bongos feedback` receipt (task 1004591): it has no
114
+ // session here, and a members-only project must still vouch for its builders.
115
+ { re: new RegExp(`^${API}/feedback/receipt/[a-z0-9_-]+$`), methods: ['GET'] },
113
116
  ];
114
117
 
115
118
  // The policy, read through the memoized pack. A pack that cannot be read at all