diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d24ee16..a1d25445 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 0.76.0 — 2026-10-03 + +### 修正: 故障や解決の直後の送信が、受け口に断られていた(ADR 0195) + +- 0.74.0・0.75.0では、報告の `observed_at` を秒へ切り捨てて作っていた。BugHubは、記録の時刻が + `observed_at` より後の報告を断る。記録と同じ1秒の中で送ると `observed_at` が記録より前になり、 + 422 `invalid_report` で断られた。自動送信は記録の直後に走るので、ほとんどの場合これに当たっていた。 +- 断られた報告は、最大1時間遅れて送り直されていた。記録は失われていない。 +- `observed_at` を、送る時刻と、載せる記録の時刻のうち最も後の時刻にした。直後の送信が最初の1回で届く。 + +### 送信の経路を確かめる `reporting verify` + +- `lattice runtime-errors reporting verify --json` を足した。記録を載せない空の報告を1通送り、合鍵・署名・ + 受け口までの経路・受領の署名を確かめる。確かめられた時だけ `verified`・exit 0。 +- storeの記録と受領の印には触れない。受け口は受領を記録するだけで、issueも通知も動かさない。 +- 送信を有効にしていない端末と、合鍵の無い端末では、networkへ触れない。 +- 受け口は端末×製品ごとに1分に1回まで受ける。`report` と続けて打つ時は1分空ける。 + ## 0.75.0 — 2026-10-03 ### runtime errorの収集と送信がWindowsで動く(ADR 0194) diff --git a/bin/lattice.mjs b/bin/lattice.mjs index b7ce6e50..aa86d9f5 100755 --- a/bin/lattice.mjs +++ b/bin/lattice.mjs @@ -185,8 +185,9 @@ await scheduleRuntimeErrorReport(); * 送信を有効にしていない端末では、設定fileの有無を1回見るだけで終わる。hookは打鍵ごとに走るので見ない。 */ async function scheduleRuntimeErrorReport() { + // `reporting verify`の直後は送らない。受け口は1分に1回まで受けるので、続けて送ると断られる。 if (help !== null || args[0] === '--version' || args[0] === 'hooks' - || (args[0] === 'runtime-errors' && args[1] === 'report')) return; + || (args[0] === 'runtime-errors' && (args[1] === 'report' || (args[1] === 'reporting' && args[2] === 'verify')))) return; try { const { runtimeErrorReportingEnabled } = await import('../src/runtime-errors.mjs'); if (!runtimeErrorReportingEnabled()) return; @@ -222,7 +223,7 @@ function workingDirectory() { async function runRuntimeErrorsCli(rest) { const runtimeErrors = await import('../src/runtime-errors.mjs'); const usage = () => { - process.stderr.write(`${JSON.stringify({ schema: 'lattice.cli_error.v2', code: 'USAGE', message: 'usage: lattice runtime-errors |diagnostics|resolve |reopen |compact|report|reporting > --json' })}\n`); + process.stderr.write(`${JSON.stringify({ schema: 'lattice.cli_error.v2', code: 'USAGE', message: 'usage: lattice runtime-errors |diagnostics|resolve |reopen |compact|report|reporting > --json' })}\n`); return 2; }; const options = { version: packageJson.version }; @@ -251,6 +252,12 @@ async function runRuntimeErrorsCli(rest) { result = runtimeErrors.setRuntimeErrorStatus(words[1], words[0] === 'resolve' ? 'resolved' : 'open', options); } else if (words[0] === 'compact' && words.length === 1) { result = runtimeErrors.compactRuntimeErrors(options); + } else if (words[0] === 'reporting' && words.length === 2 && words[1] === 'verify') { + // 空の報告を1通送って経路を確かめる。受領まで確かめられた時だけexit 0。 + const reporting = await import('../src/runtime-error-reporting.mjs'); + result = await reporting.verifyRuntimeErrorReporting(options); + process.stdout.write(`${JSON.stringify(result)}\n`); + return result.outcome === 'verified' ? 0 : 1; } else if (words[0] === 'reporting' && words.length === 2 && ['status', 'enable', 'disable'].includes(words[1])) { const reporting = await import('../src/runtime-error-reporting.mjs'); result = words[1] === 'status' ? reporting.runtimeErrorReportingStatus(options) diff --git a/docs/01_integration-package.md b/docs/01_integration-package.md index 212af217..6407c6c2 100644 --- a/docs/01_integration-package.md +++ b/docs/01_integration-package.md @@ -232,7 +232,7 @@ native Windowsでは`HOST_PLATFORM_UNSUPPORTED`を返し、設定やstateへ書 `%LOCALAPPDATA%\Lattice\runtime-errors\runtime-errors.json`で、このフォルダは継承を切って本人・SYSTEM・ Administratorsだけに絞る。他のaccountが触れる形で中身があるフォルダは、絞らずに止める。 - runtime errorの送信(ADR 0193): `lattice runtime-errors report --json`と - `lattice runtime-errors reporting --json`。Lattice自身が、未受領の記録を + `lattice runtime-errors reporting --json`。Lattice自身が、未受領の記録を BugHubの製品報告の受け口へ送る。正典は`src/runtime-error-reporting.mjs` - **既定では通信しない。** `reporting enable`を打った端末(設定は `${XDG_CONFIG_HOME:-~/.config}/lattice/runtime-error-reporting.json`)で、BugHubの持ち主が置いた合鍵のfile @@ -246,6 +246,11 @@ native Windowsでは`HOST_PLATFORM_UNSUPPORTED`を返し、設定やstateへ書 (`sig = HMAC-SHA256(secret, ts + "\n" + SHA-256(送るバイト列))`)。 - 本文は`schema_version`・`report_id`・`product_id`・`installed_version`・`observed_at`・`runtime_errors`・ `resolutions`の7項目で、各記録は`snapshot`が出す項目のまま。端末名は入れない。 + - `observed_at`は、送る時刻と、載せる記録の`last_seen`・`resolved_at`のうち最も後の時刻(ADR 0195)。 + BugHubは、記録の時刻が`observed_at`より後の報告を断る。秒へ切り捨てない。 + - `reporting verify`は、記録を載せない空の報告を1通送って、合鍵・署名・受け口までの経路を確かめる。 + storeの記録と受領の印には触れない。受け口は受領を記録するだけで、issueも通知も動かさない。 + 受領まで確かめられた時だけ`verified`・exit 0。受け口は端末×製品ごとに1分に1回まで受ける。 - 受領済み(storeのack)にするのは、200・`accepted: true`・`report_id`一致・応答の署名一致がそろった時だけ。 そろわなければ未受領のまま残し、後から新しい`report_id`でその時点の累計を送り直す。 - 送る時機: 故障を記録した直後と、以後のCLI実行(`hooks`を除く)の終わりに、切り離した子processで送る。 diff --git a/docs/adr/0193-product-owned-runtime-error-reporting.md b/docs/adr/0193-product-owned-runtime-error-reporting.md index 1a694c1b..5ec4290d 100644 --- a/docs/adr/0193-product-owned-runtime-error-reporting.md +++ b/docs/adr/0193-product-owned-runtime-error-reporting.md @@ -1,7 +1,8 @@ # ADR 0193: runtime errorの送信をLattice自身が持つ - Status: accepted(Decision 9 と、Consequences の「Windowsの端末からは送れない」は - [ADR 0194](0194-runtime-error-store-on-windows.md) が置き換える) + [ADR 0194](0194-runtime-error-store-on-windows.md) が置き換える。`observed_at`の作り方と`reporting verify`は + [ADR 0195](0195-report-observation-time-and-verify.md) が足す) - Date: 2026-10-03 - Supersedes: runtime error storeの「reporting(BugHub送信)はdotagents adapter所有」 (`docs/01_integration-package.md` 5.5、`src/runtime-errors.mjs`冒頭) diff --git a/docs/adr/0195-report-observation-time-and-verify.md b/docs/adr/0195-report-observation-time-and-verify.md new file mode 100644 index 00000000..33f58f99 --- /dev/null +++ b/docs/adr/0195-report-observation-time-and-verify.md @@ -0,0 +1,50 @@ +# ADR 0195: 報告の観測時刻は記録より前にしない。経路は空の報告で確かめる + +- Status: accepted +- Date: 2026-10-03 +- Amends: [ADR 0193](0193-product-owned-runtime-error-reporting.md)(送信の本文の作り方と、送信のcommand) + +## Context + +ADR 0193の送信を4台で有効にした日に、2つのことが分かった。 + +1. **故障や解決の直後の自動送信が、受け口に断られる。** Latticeは`observed_at`を、署名の時刻(秒)と同じ値へ + 切り捨てて作っていた。BugHubは、記録の`last_seen`や解決の`resolved_at`が`observed_at`より後の報告を + 422 `invalid_report`で断る。記録と同じ1秒の中で送ると、切り捨てた`observed_at`は記録より前になる。 + 自動送信は記録の直後に走るので、ほとんどの場合これに当たる。断られた報告は、同じ中身の送り直しの制限で、 + 最大1時間遅れて届く。記録は失われない。最初の到達確認が通ったのは、記録が秒の終わり(.999)で、 + 送信が次の秒へまたいだためだった。 +2. **Windowsの端末から届くことを、確かめる方法が無い。** 届くことを確かめるには記録が1件要る。 + 起きていない故障の記録を作って送れば、受け口の履歴に偽の故障が残り、持ち主へ通知が出る。 + 合鍵を入れ替えた時も、次の本物の故障まで、新しい合鍵で届くかが分からない。 + +## Decision + +1. `observed_at`は、送る時刻と、載せる記録の`last_seen`・`resolved_at`のうち、最も後の時刻にする。 + 秒へ切り捨てない。署名の時刻`ts`は今までどおり送る時刻の秒で、`observed_at`との差は、端末の時計が + 正しければ1秒に満たない。端末の時計が戻って記録のほうが後になった時は、`observed_at`を記録の時刻まで進める + ——差がBugHubの許す幅(10分)を越えれば、受け口が時計のずれとして断る。 +2. 受け口の検査は変えない。「記録の時刻は`observed_at`以前」はBugHubの契約で、正しい。 +3. `lattice runtime-errors reporting verify --json`を足す。記録を載せない空の報告を1通送り、 + 合鍵の読み取り・署名・受け口までの経路・受領の署名を確かめる。 + - 送る条件は`report`と同じ。送信を有効にしていない端末と、合鍵の無い端末は、networkへ触れない。 + - storeの記録、受領の印(`acknowledged_through`)、送る時機の記録には触れない。 + - 結果は`verified`(200・`accepted: true`・`report_id`一致・応答の署名一致がそろった時だけ)か、 + `report`と同じ語(`rejected`・`unconfirmed`・`disabled`等)。`verified`の時だけexit 0。 + - 受け口は、空の報告の受領を記録するだけで、issueも通知も動かさない。 +4. `reporting verify`の後のCLI実行の終わりでは、自動送信を起こさない。受け口は端末×製品ごとに + 1分に1回まで受けるので、続けて送ると断られる。 + +## Consequences + +- 故障や解決の直後の自動送信が、最初の1回で届く。 +- `reporting verify`は受け口の1分に1回の枠を使う。その直後1分以内に故障が起きると、自動送信は429で断られ、 + 1時間後(または次の記録の時)に送り直される。 +- 手で打つ`report`と`reporting verify`を続けて打つ時は、1分空ける。 + +## Acceptance + +- 記録や解決と同じ1秒の中で送った報告を、BugHubと同じ規則で検査する受け口が受領する。 +- 端末の時計が戻った時、`observed_at`は記録の時刻になる。 +- `reporting verify`は空の配列だけを載せ、確かめる前後でstoreの診断と送信の状態が変わらない。 +- 送信を有効にしていない端末と合鍵の無い端末では、`reporting verify`が受け口へ1回も接続しない。 diff --git a/package-lock.json b/package-lock.json index f4d764f6..2e4c9c03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quolu/lattice", - "version": "0.75.0", + "version": "0.76.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@quolu/lattice", - "version": "0.75.0", + "version": "0.76.0", "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { "@clack/prompts": "^1.3.0", diff --git a/package.json b/package.json index 389ef39f..f6a1fa72 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@quolu/lattice", - "version": "0.75.0", + "version": "0.76.0", "description": "Schedulability compiler for multi-agent development: observe real code boundaries, refactor the conflicting seam, recompile the plan for parallel execution", "author": { "name": "Quo / クオ at kitepon.dev", diff --git a/src/cli-help.mjs b/src/cli-help.mjs index ae2c3811..eb842d62 100644 --- a/src/cli-help.mjs +++ b/src/cli-help.mjs @@ -198,6 +198,7 @@ Commands: compact report 未受領の記録をBugHubの受け口へ送る(送信を有効にした端末だけ) reporting + reporting verify 空の報告を1通送り、合鍵と受け口までの経路を確かめる(記録には触れない) `, bridge: `Usage: lattice bridge [options] --json @@ -315,7 +316,7 @@ const SUBCOMMAND_USAGE = Object.freeze({ 'runtime-errors reopen': 'runtime-errors reopen --json', 'runtime-errors compact': 'runtime-errors compact --json', 'runtime-errors report': 'runtime-errors report --json', - 'runtime-errors reporting': 'runtime-errors reporting --json', + 'runtime-errors reporting': 'runtime-errors reporting --json', 'bridge setup': 'bridge setup --listen [--port <49152..65535|auto>] [--dashboard|--upstream ] [--hub |none] [--allow-host ...] --json', 'bridge reconfigure': 'bridge reconfigure [--listen ] [--port <49152..65535|auto>] [--dashboard|--upstream ] [--hub |none] [--allow-host ...] --json', 'bridge status': 'bridge status --json', diff --git a/src/runtime-error-reporting.mjs b/src/runtime-error-reporting.mjs index 854b84c9..1f108cd5 100644 --- a/src/runtime-error-reporting.mjs +++ b/src/runtime-error-reporting.mjs @@ -31,6 +31,8 @@ import { * - 受領済みにするのは、200・`accepted: true`・`report_id`一致・応答の署名一致がそろった時だけ。 * そろわなければ「届いたか不明」として記録を未受領のまま残し、後から今の累計を送り直す。 * - 送るのは`runtime-errors snapshot`が出す項目だけ。message本文・path・引数・stackは記録に無い。 + * - `reporting verify`は、記録を載せない空の報告を1通送って、合鍵・署名・受け口までの経路を確かめる。 + * 受け口は受領を記録するだけで、issueも通知も動かない。storeの記録と受領の印には触れない。 */ const REPORT_SCHEMA_VERSION = '1.0'; @@ -275,6 +277,41 @@ function parseJson(text) { } } +/** + * 報告を1通、署名して送り、受領を確かめる。`accepted`を返すのは、200・`accepted: true`・`report_id`一致・ + * 応答の署名一致がそろった時だけ。戻り値に秘密・key_id・宛先は入れない。 + */ +async function sendReport({ url, keyId, secret }, snapshot, options) { + const now = nowMs(options); + const ts = Math.floor(now / 1000); + // `observed_at`は、載せる記録のどの時刻よりも前にしない(BugHubは観測より後の記録を断る)。秒へ切り捨てると、 + // 記録と同じ1秒の中で送った時に記録より前へずれる。`ts`とは同じ時刻から作り、差はBugHubの許す幅(10分)に収まる。 + const observed = Math.max(now, ...snapshot.runtime_errors.map((record) => Date.parse(record.last_seen)), + ...snapshot.resolutions.map((resolution) => Date.parse(resolution.resolved_at))); + const report = buildRuntimeErrorReport({ snapshot, version: options.version ?? 'unknown', + reportId: options.reportId ?? randomUUID(), observedAt: new Date(observed).toISOString() }); + const bodyBytes = Buffer.from(JSON.stringify(report), 'utf8'); + if (bodyBytes.length > REPORT_MAX_BYTES) return { outcome: 'too_large', reason: 'report_too_large', http_status: null }; + + const response = await post(url, { + 'Content-Type': 'application/json', + 'Content-Length': String(bodyBytes.length), + Authorization: `BugHub-HMAC-SHA256 key_id=${keyId}, ts=${ts}, sig=${signReport(secret, String(ts), bodyBytes)}`, + }, bodyBytes, options.timeoutMs ?? REQUEST_TIMEOUT_MS); + + if (response.status === null) return { outcome: 'unconfirmed', reason: response.reason, http_status: null }; + const body = parseJson(response.body); + if (response.status === 200) { + if (body === null || body.accepted !== true || body.report_id !== report.report_id + || !receiptSignatureMatches(secret, report.report_id, body.received_at, body.sig)) { + return { outcome: 'unconfirmed', reason: 'receipt_not_verified', http_status: 200 }; + } + return { outcome: 'accepted', reason: null, http_status: 200 }; + } + const code = typeof body?.error === 'string' && REJECTION_CODE.test(body.error) ? body.error : 'unrecognized_response'; + return { outcome: response.status >= 500 ? 'unconfirmed' : 'rejected', reason: code, http_status: response.status }; +} + /** * 未受領の記録を1回送る。`options.auto`の時だけ送る時機の制限を見る(手で打った`report`は見ない * ——多すぎればBugHubが429で断る)。戻り値に秘密・key_id・宛先は入れない。 @@ -305,13 +342,6 @@ export async function reportRuntimeErrors(options = {}) { if (reason !== null) return result('throttled', { reason }); } - // `ts`と`observed_at`は同じ時刻から作る(BugHubは10分より離れた組を断る)。 - const ts = Math.floor(nowMs(options) / 1000); - const report = buildRuntimeErrorReport({ snapshot: pending, version: options.version ?? 'unknown', - reportId: options.reportId ?? randomUUID(), observedAt: new Date(ts * 1000).toISOString() }); - const bodyBytes = Buffer.from(JSON.stringify(report), 'utf8'); - if (bodyBytes.length > REPORT_MAX_BYTES) return result('rejected', { reason: 'report_too_large', sent }); - const finish = (outcome, extra = {}) => { try { writeDelivery(options, { schema: DELIVERY_SCHEMA, last_attempt_at: new Date(nowMs(options)).toISOString(), @@ -320,31 +350,33 @@ export async function reportRuntimeErrors(options = {}) { return result(outcome, { sent, acknowledged_through: pending.cursor.acknowledged_through, ...extra }); }; - const { credential: { url, keyId, secret } } = credential; - const response = await post(url, { - 'Content-Type': 'application/json', - 'Content-Length': String(bodyBytes.length), - Authorization: `BugHub-HMAC-SHA256 key_id=${keyId}, ts=${ts}, sig=${signReport(secret, String(ts), bodyBytes)}`, - }, bodyBytes, options.timeoutMs ?? REQUEST_TIMEOUT_MS); - - if (response.status === null) return finish('unconfirmed', { reason: response.reason }); - const body = parseJson(response.body); - if (response.status === 200) { - if (body === null || body.accepted !== true || body.report_id !== report.report_id - || !receiptSignatureMatches(secret, report.report_id, body.received_at, body.sig)) { - return finish('unconfirmed', { reason: 'receipt_not_verified', http_status: 200 }); - } - try { - const acknowledged = acknowledgeRuntimeErrors(pending.cursor.next, options); - return finish('delivered', { http_status: 200, acknowledged_through: acknowledged.cursor.acknowledged_through }); - } catch { - // 届いているが受領済みを残せなかった。次の送信が同じ累計を送り直す(BugHubは二重に数えない)。 - return finish('unconfirmed', { reason: 'ack_not_recorded', http_status: 200 }); - } + const answer = await sendReport(credential.credential, pending, options); + if (answer.outcome === 'too_large') return result('rejected', { reason: 'report_too_large', sent }); + if (answer.outcome !== 'accepted') return finish(answer.outcome, { reason: answer.reason, http_status: answer.http_status }); + try { + const acknowledged = acknowledgeRuntimeErrors(pending.cursor.next, options); + return finish('delivered', { http_status: 200, acknowledged_through: acknowledged.cursor.acknowledged_through }); + } catch { + // 届いているが受領済みを残せなかった。次の送信が同じ累計を送り直す(BugHubは二重に数えない)。 + return finish('unconfirmed', { reason: 'ack_not_recorded', http_status: 200 }); } - const code = typeof body?.error === 'string' && REJECTION_CODE.test(body.error) ? body.error : 'unrecognized_response'; - if (response.status >= 500) return finish('unconfirmed', { reason: code, http_status: response.status }); - return finish('rejected', { reason: code, http_status: response.status }); +} + +/** + * 記録を載せない空の報告を1通送り、合鍵・署名・受け口までの経路を確かめる(`reporting verify`)。 + * 送る条件は`report`と同じで、送信を有効にしていない端末と合鍵の無い端末はnetworkへ触れない。 + * storeの記録・受領の印・送る時機の記録には触れない。受け口は端末×製品ごとに1分に1回まで受けるので、 + * 直前に送っていれば429で断られる。 + */ +export async function verifyRuntimeErrorReporting(options = {}) { + if (!runtimeErrorCollectionSupported(options)) return result('unsupported'); + if (!runtimeErrorReportingEnabled(options)) return result('disabled'); + const credential = readProductCredential(options); + if (credential.status === 'missing') return result('credential_missing'); + if (credential.status !== 'ok') return result('credential_unsafe', { reason: credential.reason }); + const answer = await sendReport(credential.credential, { runtime_errors: [], resolutions: [] }, options); + if (answer.outcome === 'accepted') return result('verified', { http_status: 200 }); + return result(answer.outcome, { reason: answer.reason, http_status: answer.http_status }); } /** diff --git a/test/runtime-error-reporting.test.mjs b/test/runtime-error-reporting.test.mjs index 53f35a36..9794c1ba 100644 --- a/test/runtime-error-reporting.test.mjs +++ b/test/runtime-error-reporting.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import { createHash, createHmac } from 'node:crypto'; import { chmod, mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'; import http from 'node:http'; @@ -23,6 +23,7 @@ import { runtimeErrorReportingStatus, setRuntimeErrorReporting, signReport, + verifyRuntimeErrorReporting, } from '../src/runtime-error-reporting.mjs'; import { restrictWindowsDirToOwner, windowsSelfSid } from '../src/windows-owner-only.mjs'; @@ -178,8 +179,10 @@ nodeTest('未受領の記録を署名つきで送り、署名つきの受領で const intake = await startIntake(signedReceipt); const workspace = await makeWorkspace(intake.url); try { - fail(workspace.options, 'todo.status', 'ENOTDIR'); - fail(workspace.options, 'run.list', 'ENOENT'); + // 記録は送る時刻より前に起きている(BugHubは、観測より後の時刻の記録を断る)。 + const earlier = { ...workspace.options, now: '2026-10-03T08:59:58.000Z' }; + fail(earlier, 'todo.status', 'ENOTDIR'); + fail(earlier, 'run.list', 'ENOENT'); assert.equal(runtimeErrorAutoReportDue(workspace.options), true); const now = '2026-10-03T09:00:00.000Z'; @@ -232,6 +235,89 @@ nodeTest('未受領の記録を署名つきで送り、署名つきの受領で } }); +/** BugHubの検査と同じ規則: 記録の時刻が`observed_at`より後なら422で断る。 */ +const strictIntake = (entry) => { + const observed = Date.parse(entry.body.observed_at); + const late = [...entry.body.runtime_errors.map((record) => record.last_seen), + ...entry.body.resolutions.map((resolution) => resolution.resolved_at)].some((time) => Date.parse(time) > observed); + return late ? { status: 422, body: { error: 'invalid_report' } } : signedReceipt(entry); +}; + +nodeTest('observed_atは載せる記録の時刻より前にならない: 記録と同じ1秒の中で送っても受領される', async () => { + const intake = await startIntake(strictIntake); + const workspace = await makeWorkspace(intake.url); + const at = (now) => ({ ...workspace.options, now }); + try { + // 故障の直後(同じ秒の50ms後)に送る。秒へ切り捨てた`observed_at`は記録より前になり、断られていた。 + fail(at('2026-10-03T12:22:47.900Z'), 'todo.status', 'ENOTDIR'); + const first = await reportRuntimeErrors(at('2026-10-03T12:22:47.950Z')); + assert.deepEqual([first.outcome, first.http_status], ['delivered', 200]); + assert.equal(intake.requests[0].body.observed_at, '2026-10-03T12:22:47.950Z'); + // 署名の時刻は秒のまま。`observed_at`との差は1秒に満たない。 + assert.match(intake.requests[0].headers.authorization, / ts=1791030167, /); + + // 解決を打った直後も同じ。 + const fingerprint = intake.requests[0].body.runtime_errors[0].fingerprint; + setRuntimeErrorStatus(fingerprint, 'resolved', at('2026-10-03T12:22:48.921Z')); + const second = await reportRuntimeErrors(at('2026-10-03T12:22:48.990Z')); + assert.deepEqual([second.outcome, second.sent], ['delivered', { runtime_errors: 0, resolutions: 1 }]); + assert.equal(intake.requests[1].body.observed_at, '2026-10-03T12:22:48.990Z'); + + // 端末の時計が少し戻った時: 記録の時刻のほうが後なら、`observed_at`をその時刻まで進める。 + fail(at('2026-10-03T12:23:10.500Z'), 'run.list', 'ENOENT'); + const third = await reportRuntimeErrors(at('2026-10-03T12:23:09.000Z')); + assert.equal(third.outcome, 'delivered'); + assert.equal(intake.requests[2].body.observed_at, '2026-10-03T12:23:10.500Z'); + assert.equal(runtimeErrorsDiagnostics(workspace.options).pending_count, 0); + } finally { + await intake.close(); + await rm(workspace.root, { recursive: true, force: true }); + } +}); + +nodeTest('reporting verify: 空の報告を1通送って経路を確かめ、記録と受領の印には触れない', async () => { + const answers = [signedReceipt, () => ({ status: 429, body: { error: 'rate_limited' } }), + (entry) => ({ ...signedReceipt(entry), body: { ...signedReceipt(entry).body, sig: 'f'.repeat(64) } })]; + const intake = await startIntake((entry, count) => answers[count - 1](entry)); + const workspace = await makeWorkspace(intake.url); + const disabled = await makeWorkspace(intake.url, { enabled: false }); + const noCredential = await makeWorkspace(intake.url, { credential: false }); + try { + // 送信を有効にしていない端末と、合鍵の無い端末は、確かめる時もnetworkへ触れない。 + assert.equal((await verifyRuntimeErrorReporting(disabled.options)).outcome, 'disabled'); + assert.equal((await verifyRuntimeErrorReporting(noCredential.options)).outcome, 'credential_missing'); + assert.equal((await verifyRuntimeErrorReporting({ ...workspace.options, platform: 'freebsd' })).outcome, 'unsupported'); + assert.equal(intake.requests.length, 0); + + // 未受領の記録があっても、載せるのは空の配列だけ。 + fail(workspace.options, 'run.list', 'ENOENT'); + const before = [runtimeErrorsDiagnostics(workspace.options), runtimeErrorReportingStatus(workspace.options)]; + const now = '2026-10-03T12:30:00.250Z'; + const verified = await verifyRuntimeErrorReporting({ ...workspace.options, now }); + assert.deepEqual([verified.outcome, verified.http_status, verified.sent], ['verified', 200, { runtime_errors: 0, resolutions: 0 }]); + const [request] = intake.requests; + assert.deepEqual([request.body.runtime_errors, request.body.resolutions, request.body.observed_at], [[], [], now]); + assert.deepEqual(Object.keys(request.body), ['schema_version', 'report_id', 'product_id', 'installed_version', + 'observed_at', 'runtime_errors', 'resolutions']); + const ts = String(Math.floor(Date.parse(now) / 1000)); + assert.equal(request.headers.authorization, + `BugHub-HMAC-SHA256 key_id=${KEY_ID}, ts=${ts}, sig=${signReport(SECRET, ts, request.bytes)}`); + assert.equal(JSON.stringify(verified).includes(KEY_ID), false); + // 記録・受領の印・送る時機の記録は、確かめる前と同じ。 + assert.deepEqual([runtimeErrorsDiagnostics(workspace.options), runtimeErrorReportingStatus(workspace.options)], before); + assert.equal(before[0].pending_count, 1); + + // 受け口が断った時と、受領の署名が合わない時は、確かめられたとは答えない。 + const limited = await verifyRuntimeErrorReporting(workspace.options); + assert.deepEqual([limited.outcome, limited.reason, limited.http_status], ['rejected', 'rate_limited', 429]); + const forged = await verifyRuntimeErrorReporting(workspace.options); + assert.deepEqual([forged.outcome, forged.reason], ['unconfirmed', 'receipt_not_verified']); + } finally { + await intake.close(); + for (const item of [workspace, disabled, noCredential]) await rm(item.root, { recursive: true, force: true }); + } +}); + nodeTest('受領を確かめられない応答では受領済みにせず、後から今の累計を送り直す', async () => { const answers = [ (entry) => ({ status: 200, body: { accepted: true, report_id: entry.body.report_id, received_at: '2026-10-03T09:00:01.000Z', sig: 'f'.repeat(64) } }), @@ -360,13 +446,30 @@ nodeTest('CLI: 送信を有効にした端末では、故障を記録した直 assert.deepEqual(intake.requests[0].body.runtime_errors[0].safe_context, { command_kind: 'todo.status', error_kind: 'Error', cause_code: 'ENOTDIR' }); - // 手で打つ送信: 送るものが無ければ通信せずexit 0。無効に戻せば送らない。 + // 手で打つ送信: 送るものが無ければ通信せずexit 0。 const manual = cli(['runtime-errors', 'report', '--json']); assert.deepEqual([manual.status, json(manual).outcome], [0, 'nothing_pending']); + assert.equal(intake.requests.length, 1); + + // 経路の確認: 空の報告を1通送り、受領まで確かめられればexit 0。 + // このprocessが受け口を兼ねるので、応答を返せるよう、待ち合わせる形でCLIを起こす。 + const verify = await new Promise((resolve, reject) => { + const child = spawn(process.execPath, [cliPath, 'runtime-errors', 'reporting', 'verify', '--json'], { cwd: root, env }); + let stdout = ''; + child.stdout.setEncoding('utf8').on('data', (chunk) => { stdout += chunk; }); + child.once('error', reject).once('close', (status) => resolve({ status, stdout })); + }); + assert.deepEqual([verify.status, json(verify).outcome], [0, 'verified']); + assert.equal(intake.requests.length, 2); + assert.deepEqual([intake.requests[1].body.runtime_errors, intake.requests[1].body.resolutions], [[], []]); + + // 無効に戻せば、送信も確認もnetworkへ触れない。 assert.equal(json(cli(['runtime-errors', 'reporting', 'disable', '--json'])).reporting, 'disabled'); const off = cli(['runtime-errors', 'report', '--json']); assert.deepEqual([off.status, json(off).outcome], [1, 'disabled']); - assert.equal(intake.requests.length, 1); + const verifyOff = cli(['runtime-errors', 'reporting', 'verify', '--json']); + assert.deepEqual([verifyOff.status, json(verifyOff).outcome], [1, 'disabled']); + assert.equal(intake.requests.length, 2); } finally { await intake.close(); await rm(root, { recursive: true, force: true });