diff --git a/CHANGELOG.md b/CHANGELOG.md index 59f72c8a..e2cf2b28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 0.74.0 — 2026-10-03 + +### runtime errorの送信をLattice自身が持つ(ADR 0193) + +- Latticeが、未受領のruntime error記録をBugHubの製品報告の受け口へ自分で送る。これまでは工場 + (dotagents)のreportが運んでいた。 +- **既定では通信しない。** 送るのは、端末で `lattice runtime-errors reporting enable --json` を打ち、 + BugHubの持ち主が合鍵のfile(`~/.config/bughub/product-credentials/lattice.json`)を置いた端末だけ。 + どちらかが欠ければnetworkへ触れない。設定を変えていない端末の動きは今までと同じ。 +- 新しいcommand: + - `lattice runtime-errors reporting --json` + - `lattice runtime-errors report --json`(未受領の分を今送る。受領まで済むか、送るものが無ければexit 0) +- 秘密は通信に載せず、送るバイト列と時刻へのHMAC-SHA256署名だけを付ける。受領済みにするのは、 + 署名つきの200が返った時だけ。届いたか分からない時は未受領のまま残し、後から送り直す。 +- 送る時機: 故障を記録した直後と、以後のCLI実行(`hooks` を除く)の終わりに、切り離した子processで送る。 + CLIの応答は待たせない。1分に1回まで、同じ中身の送り直しは1時間に1回まで。 +- 送信を有効にした端末では、工場の設定が無くても収集が有効になる。 +- `LATTICE_RUNTIME_ERROR_REPORTING=0` は、既定の置き場の送信設定を読まない(試験と自動化の口)。 +- Windowsは収集に対応しないので、送信も `unsupported` と答える。 + ## 0.73.0 — 2026-10-03 ### runtime errorの収集: 対応しないOSは `unsupported` と答える diff --git a/bin/lattice.mjs b/bin/lattice.mjs index 8ed69f37..b7ce6e50 100755 --- a/bin/lattice.mjs +++ b/bin/lattice.mjs @@ -177,6 +177,29 @@ if (help !== null) { } } +await pendingObservation; +await scheduleRuntimeErrorReport(); + +/** + * 未受領のruntime error記録を、切り離した子processで送らせる(ADR 0193)。このCLIの応答は待たせない。 + * 送信を有効にしていない端末では、設定fileの有無を1回見るだけで終わる。hookは打鍵ごとに走るので見ない。 + */ +async function scheduleRuntimeErrorReport() { + if (help !== null || args[0] === '--version' || args[0] === 'hooks' + || (args[0] === 'runtime-errors' && args[1] === 'report')) return; + try { + const { runtimeErrorReportingEnabled } = await import('../src/runtime-errors.mjs'); + if (!runtimeErrorReportingEnabled()) return; + const { runtimeErrorAutoReportDue } = await import('../src/runtime-error-reporting.mjs'); + if (!runtimeErrorAutoReportDue()) return; + const { spawn } = await import('node:child_process'); + const { tmpdir } = await import('node:os'); + const { fileURLToPath } = await import('node:url'); + spawn(process.execPath, [fileURLToPath(import.meta.url), 'runtime-errors', 'report', '--auto', '--json'], + { cwd: tmpdir(), detached: true, stdio: 'ignore', windowsHide: true }).unref(); + } catch { /* 送信の予約に失敗しても、打たれたcommandの結果は変えない。 */ } +} + // typed契約の外へ漏れた例外を、opt-inのruntime error記録へ残す。どの面で・どの種類の例外で // 落ちたかを固定語彙の分類(safe_context)で添える——error_codeだけでは原因を追えなかった。 function observeInternalFailure(error, commandKind) { @@ -199,7 +222,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> --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 }; @@ -228,6 +251,16 @@ 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 && ['status', 'enable', 'disable'].includes(words[1])) { + const reporting = await import('../src/runtime-error-reporting.mjs'); + result = words[1] === 'status' ? reporting.runtimeErrorReportingStatus(options) + : reporting.setRuntimeErrorReporting(words[1] === 'enable', options); + } else if (words[0] === 'report' && (words.length === 1 || (words.length === 2 && words[1] === '--auto'))) { + // 送れなかった時も結果は1行で返す。受領まで済んだ時と、送るものが無い時だけexit 0。 + const reporting = await import('../src/runtime-error-reporting.mjs'); + result = await reporting.reportRuntimeErrors({ ...options, auto: words.length === 2 }); + process.stdout.write(`${JSON.stringify(result)}\n`); + return ['delivered', 'nothing_pending'].includes(result.outcome) ? 0 : 1; } else { return usage(); } diff --git a/docs/01_integration-package.md b/docs/01_integration-package.md index 2f65137e..b3df97aa 100644 --- a/docs/01_integration-package.md +++ b/docs/01_integration-package.md @@ -221,10 +221,27 @@ native Windowsでは`HOST_PLATFORM_UNSUPPORTED`を返し、設定やstateへ書 check 5本・overall failed→exit 1・read-only・秘密なし)。正典は`src/factory-diagnostics.mjs` - runtime error store: `lattice runtime-errors --json` (schema `lattice.runtime_errors.v1`。Caveat同型の工場契約)。**opt-in**=工場共有config - `~/.config/dotagents/factory-reporter.json`の`collection.enabled`のみが収集を有効化し、 - reporting(BugHub送信)はdotagents adapter所有で本storeは外部送信しない(collection/reporting分離)。 + `~/.config/dotagents/factory-reporter.json`の`collection.enabled`か、Lattice自身の送信設定(下)の + どちらかが有効な時だけ収集する。storeそのものは外部送信しない。 固定catalog 5 code・fingerprint集約・cursor/ack・resolved+ack済み30日compact・POSIX owner-only検査で fail closed。正典は`src/runtime-errors.mjs` +- runtime errorの送信(ADR 0193): `lattice runtime-errors report --json`と + `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 + (`~/.config/bughub/product-credentials/lattice.json`、本人所有・0600・symlinkでない)がある時だけ送る。 + dotagentsの設定は読まない。宛先は合鍵のfileの`url`。 + - 秘密は通信に載せない。`Authorization: BugHub-HMAC-SHA256 key_id=…, ts=…, sig=…` + (`sig = HMAC-SHA256(secret, ts + "\n" + SHA-256(送るバイト列))`)。 + - 本文は`schema_version`・`report_id`・`product_id`・`installed_version`・`observed_at`・`runtime_errors`・ + `resolutions`の7項目で、各記録は`snapshot`が出す項目のまま。端末名は入れない。 + - 受領済み(storeのack)にするのは、200・`accepted: true`・`report_id`一致・応答の署名一致がそろった時だけ。 + そろわなければ未受領のまま残し、後から新しい`report_id`でその時点の累計を送り直す。 + - 送る時機: 故障を記録した直後と、以後のCLI実行(`hooks`を除く)の終わりに、切り離した子processで送る。 + 1分に1回まで、同じ中身の送り直しは1時間に1回まで。手で打つ`report`はこの制限を見ない。 + - `LATTICE_RUNTIME_ERROR_REPORTING=0`は、既定の置き場の送信設定を読まない(試験と自動化の口)。 + - Windowsは収集に対応しないので、送信も`unsupported`と答える。 - `diagnostics.collection`は`enabled`・`disabled`・`unsupported`の3値。`unsupported`は「このOSでは収集に 対応しない」という製品の答えで、Windowsが返す(storeの所有者と権限をPOSIXの形で確かめられない)。 設定が有効でも記録は作らない。`status`・`cursor`・配列・`diagnostics`のキーは`disabled`の時と同じ形 diff --git a/docs/adr/0193-product-owned-runtime-error-reporting.md b/docs/adr/0193-product-owned-runtime-error-reporting.md new file mode 100644 index 00000000..5baf85f6 --- /dev/null +++ b/docs/adr/0193-product-owned-runtime-error-reporting.md @@ -0,0 +1,59 @@ +# ADR 0193: runtime errorの送信をLattice自身が持つ + +- Status: accepted +- Date: 2026-10-03 +- Supersedes: runtime error storeの「reporting(BugHub送信)はdotagents adapter所有」 + (`docs/01_integration-package.md` 5.5、`src/runtime-errors.mjs`冒頭) + +## Context + +Latticeのruntime error記録は、工場(dotagents)のfactory reporterが`runtime-errors snapshot`を読み、 +工場のreportに載せてBugHubへ運んでいた。Lattice自身は外部へ送らなかった。 + +オーナーの裁定(2026-10-03)で責務が変わった。エラーを上げるのは各プロダクトの責務で、 +工場が持つのは工場の方針と、プロダクト・パッケージの管理だけである。BugHubは、端末に入る +CLI製品が自分の分だけを送る受け口(製品報告)を用意する。この受け口はオーナーの端末だけが使い、 +LANの中に置く。LatticeはOSSなので、外の利用者の端末からは送らない。 + +`AGENTS.md`は、Latticeの設定とstateはこのrepoだけを正本とし、dotagentsは実行条件ではないと定めている。 +収集の有効化が工場の設定fileだけに依っている点は、この原則からも外れていた。 + +## Decision + +1. Latticeは、未受領のruntime error記録をBugHubの製品報告の受け口へ自分で送る。 + 入口は`lattice runtime-errors report --json`、実装は`src/runtime-error-reporting.mjs`。 +2. **既定では通信しない。** 送るのは、端末で`lattice runtime-errors reporting enable --json`を打ち + (設定は`${XDG_CONFIG_HOME:-~/.config}/lattice/runtime-error-reporting.json`)、かつBugHubの持ち主が + 合鍵のfile(`~/.config/bughub/product-credentials/lattice.json`)を置いた端末だけである。 + どちらかが欠ければnetworkへ触れない。dotagentsの設定は、送信の判断に使わない。 +3. 送信設定が有効な端末では、工場の設定が無くても収集を有効にする。工場の設定による収集の有効化は残す + (工場のreportが運ぶ経路を、移行が済むまで壊さない)。 +4. 合鍵のfileは、本人所有・0600・symlinkでない通常fileで、`url`・`key_id`・`secret`の3項目だけを持つ形に限る。 + それ以外は使わず、理由を返す。秘密は通信・結果・記録のどこにも写さない。 +5. 秘密は通信に載せない。送ったバイト列のSHA-256と時刻へのHMAC-SHA256署名を`Authorization`に付ける。 + 宛先は平文のHTTPで、持ち歩く端末が外のnetworkで同じaddressの別の機器へ送っても、秘密は漏れない。 +6. 受領済みにするのは、200・`accepted: true`・`report_id`一致・応答の署名一致がそろった時だけである。 + 受領済みの印はstoreの`acknowledged_through`を使う。そろわない時は「届いたか不明」として未受領のまま残し、 + 後から新しい`report_id`でその時点の累計を送り直す(BugHubは同じ回数・同じ最終時刻を二重に数えない)。 +7. 送る時機はLatticeが決める。故障を記録した直後と、以後のCLI実行の終わりに、切り離した子processで送る。 + CLIの応答は送信を待たない。`hooks`(打鍵ごとに走る)は時機に数えない。自動送信は1分に1回まで、 + 同じ中身の送り直しは1時間に1回までとする。手で打つ`report`はこの制限を見ない。 +8. 本文に載せるのは、`runtime-errors snapshot`が出す記録の項目だけである。端末名は載せない + (BugHubが合鍵から解決する)。 +9. Windowsは収集に対応しない(`collection: unsupported`)。送信も`unsupported`と答える。 + +## Consequences + +- 工場のreportとLatticeの両方から同じ記録が届く期間がある。BugHubの同一性は端末・製品・fingerprintで、 + 二重にはならない。Latticeが送り始めたら、工場のreportからLatticeの分を外す。 +- 受領済みの印を工場のackと共有する。どちらのackも「BugHubへ届いた」を意味する。 +- Windowsの端末からは送れない。Windowsでstoreを安全に置く方法が決まるまで残る不具合である。 +- 試験と自動化は`LATTICE_RUNTIME_ERROR_REPORTING=0`で既定の置き場の送信設定を読まない。CIのrunnerは + 利用者の本物のHOMEで走るので、製品試験の環境はこの値を必ず持つ。 + +## Acceptance + +- 署名と応答の署名が、BugHubの契約の試験値と一致する。 +- 送信を有効にしていない端末と、合鍵の無い端末は、受け口へ1回も接続しない。 +- 署名の合わない200・別の`report_id`・`accepted: false`・5xx・時間切れでは、storeのackが進まない。 +- 送信を有効にした端末で故障を1件起こすと、CLIが返った後に子processが届け、ackが進む。 diff --git a/package-lock.json b/package-lock.json index d05f0e34..5f3392a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quolu/lattice", - "version": "0.73.0", + "version": "0.74.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@quolu/lattice", - "version": "0.73.0", + "version": "0.74.0", "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { "@clack/prompts": "^1.3.0", diff --git a/package.json b/package.json index 20858042..c3f501ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@quolu/lattice", - "version": "0.73.0", + "version": "0.74.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/scripts/run-product-tests.mjs b/scripts/run-product-tests.mjs index e28f7773..ede63e38 100644 --- a/scripts/run-product-tests.mjs +++ b/scripts/run-product-tests.mjs @@ -92,8 +92,9 @@ export function productTestEnvironment(parentEnv = process.env) { // sensor init用WASM poolまで最大8本prewarmするとnested oversubscriptionになり、 // Windowsで複数の子processが0xC0000005になった。sensor自身の並列契約は // 独立したtest:sensor gateが検証するため、このharness内だけsingle-workerにする。 + // runnerは利用者の本物のHOMEで走る。送信を有効にした端末でも、試験がその設定を拾ってBugHubへ送らない。 const env = { ...parentEnv, LATTICE_DASHBOARD_AUTOSTART: '0', - LATTICE_SENSOR_PARSE_WORKERS: '1' }; + LATTICE_SENSOR_PARSE_WORKERS: '1', LATTICE_RUNTIME_ERROR_REPORTING: '0' }; delete env.FORCE_COLOR; return env; } diff --git a/src/cli-help.mjs b/src/cli-help.mjs index d9b71873..ae2c3811 100644 --- a/src/cli-help.mjs +++ b/src/cli-help.mjs @@ -196,6 +196,8 @@ Commands: resolve reopen compact + report 未受領の記録をBugHubの受け口へ送る(送信を有効にした端末だけ) + reporting `, bridge: `Usage: lattice bridge [options] --json @@ -312,6 +314,8 @@ const SUBCOMMAND_USAGE = Object.freeze({ 'runtime-errors resolve': 'runtime-errors resolve --json', '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', '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 new file mode 100644 index 00000000..dc6f3c33 --- /dev/null +++ b/src/runtime-error-reporting.mjs @@ -0,0 +1,336 @@ +import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; +import { + existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, +} from 'node:fs'; +import http from 'node:http'; +import https from 'node:https'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { + REPORTING_CONFIG_SCHEMA, + acknowledgeRuntimeErrors, + ensureSafeDir, + ensureSafeFile, + runtimeErrorCollectionSupported, + runtimeErrorReportingConfigPath, + runtimeErrorReportingEnabled, + runtimeErrorsDiagnostics, + runtimeErrorsSnapshot, + runtimeErrorsStatePath, +} from './runtime-errors.mjs'; + +/** + * runtime error記録を、Lattice自身がBugHubの製品報告の受け口へ送る(ADR 0193)。 + * + * - 既定では通信しない。端末で`runtime-errors reporting enable`を打ち、BugHubの持ち主が合鍵のfileを + * 置いた端末だけが送る。どちらかが欠ければ、networkへ触れない。 + * - 秘密は通信に載せない。本文のSHA-256と時刻へのHMAC-SHA256署名だけを`Authorization`に付ける。 + * - 受領済みにするのは、200・`accepted: true`・`report_id`一致・応答の署名一致がそろった時だけ。 + * そろわなければ「届いたか不明」として記録を未受領のまま残し、後から今の累計を送り直す。 + * - 送るのは`runtime-errors snapshot`が出す項目だけ。message本文・path・引数・stackは記録に無い。 + */ + +const REPORT_SCHEMA_VERSION = '1.0'; +const RESULT_SCHEMA = 'lattice.runtime_error_report_result.v1'; +const STATUS_SCHEMA = 'lattice.runtime_error_reporting_status.v1'; +const DELIVERY_SCHEMA = 'lattice.runtime_error_delivery.v1'; +const PRODUCT_ID = 'lattice'; +const REQUEST_TIMEOUT_MS = 10_000; +const RESPONSE_MAX_BYTES = 64 * 1024; +const REPORT_MAX_BYTES = 512 * 1024; +const REPORT_MAX_RECORDS = 256; +// BugHubは端末×製品ごとに1分に1回まで受ける。届かなかった同じ中身の送り直しは1時間に1回まで。 +const MIN_ATTEMPT_INTERVAL_MS = 60_000; +const RETRY_INTERVAL_MS = 60 * 60_000; +const KEY_ID = /^[A-Za-z0-9._:-]{1,128}$/; +const REJECTION_CODE = /^[a-z][a-z0-9_]{0,63}$/; +const OUTCOMES = Object.freeze(['delivered', 'nothing_pending', 'disabled', 'unsupported', 'credential_missing', + 'credential_unsafe', 'throttled', 'rejected', 'unconfirmed', 'store_unavailable']); + +const plain = (value) => typeof value === 'object' && value !== null && !Array.isArray(value); +const exact = (value, keys) => Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); + +export function productCredentialPath(env = process.env) { + return join(env.HOME || homedir(), '.config', 'bughub', 'product-credentials', `${PRODUCT_ID}.json`); +} + +/** + * 合鍵のfileを読む。BugHubの持ち主が置く形(本人所有・0600・symlinkでない)以外は使わない。 + * 秘密は戻り値の中だけに留め、結果や記録へ写さない。 + */ +export function readProductCredential(options = {}) { + const path = options.credentialPath ?? productCredentialPath(options.env ?? process.env); + let stats; + try { + stats = lstatSync(path); + } catch (error) { + return error?.code === 'ENOENT' ? { status: 'missing' } : { status: 'unsafe', reason: 'unreadable' }; + } + if (!stats.isFile() || stats.isSymbolicLink()) return { status: 'unsafe', reason: 'not_regular_file' }; + if ((stats.mode & 0o777) !== 0o600) return { status: 'unsafe', reason: 'mode_not_0600' }; + if (typeof process.getuid === 'function' && stats.uid !== process.getuid()) return { status: 'unsafe', reason: 'owner_mismatch' }; + let value; + try { + value = JSON.parse(readFileSync(path, 'utf8')); + } catch { + return { status: 'unsafe', reason: 'not_json' }; + } + if (!plain(value) || !exact(value, ['url', 'key_id', 'secret']) + || typeof value.url !== 'string' || typeof value.key_id !== 'string' || typeof value.secret !== 'string' + || !KEY_ID.test(value.key_id) || value.secret.length < 16 || value.secret.length > 1024) { + return { status: 'unsafe', reason: 'invalid_shape' }; + } + let url; + try { + url = new URL(value.url); + } catch { + return { status: 'unsafe', reason: 'invalid_url' }; + } + if (!['http:', 'https:'].includes(url.protocol) || url.username !== '' || url.password !== '') { + return { status: 'unsafe', reason: 'invalid_url' }; + } + return { status: 'ok', credential: { url, keyId: value.key_id, secret: value.secret } }; +} + +const sha256Hex = (bytes) => createHash('sha256').update(bytes).digest('hex'); +const hmacHex = (secret, text) => createHmac('sha256', Buffer.from(secret, 'utf8')).update(text, 'utf8').digest('hex'); + +/** `sig = HMAC-SHA256(secret, ts + "\n" + SHA-256(送るバイト列の16進))`。秘密は文字列のままUTF-8で鍵にする。 */ +export function signReport(secret, ts, bodyBytes) { + return hmacHex(secret, `${ts}\n${sha256Hex(bodyBytes)}`); +} + +/** 応答の署名: `HMAC-SHA256(secret, report_id + "\n" + received_at)`。別の機器が返した200を受領済みにしない。 */ +export function receiptSignatureMatches(secret, reportId, receivedAt, sig) { + if (typeof receivedAt !== 'string' || typeof sig !== 'string' || !/^[0-9a-f]{64}$/.test(sig)) return false; + const expected = Buffer.from(hmacHex(secret, `${reportId}\n${receivedAt}`), 'hex'); + return timingSafeEqual(expected, Buffer.from(sig, 'hex')); +} + +/** snapshotの未受領分から、受け口へ送る本文を組む。項目はsnapshotが出すものをそのまま載せる。 */ +export function buildRuntimeErrorReport({ snapshot, version, reportId, observedAt }) { + return { + schema_version: REPORT_SCHEMA_VERSION, + report_id: reportId, + product_id: PRODUCT_ID, + installed_version: version, + observed_at: observedAt, + runtime_errors: snapshot.runtime_errors, + resolutions: snapshot.resolutions, + }; +} + +function deliveryStatePath(options) { + const env = options.env ?? process.env; + return join(dirname(options.storePath ?? runtimeErrorsStatePath(env)), 'runtime-errors-delivery.json'); +} + +const emptyDelivery = () => ({ schema: DELIVERY_SCHEMA, last_attempt_at: null, last_outcome: null, attempted_through: 0 }); + +function readDelivery(options) { + const path = deliveryStatePath(options); + if (!existsSync(path)) return emptyDelivery(); + try { + ensureSafeFile(path); + const value = JSON.parse(readFileSync(path, 'utf8')); + if (!plain(value) || !exact(value, ['schema', 'last_attempt_at', 'last_outcome', 'attempted_through']) + || value.schema !== DELIVERY_SCHEMA + || (value.last_attempt_at !== null && !Number.isFinite(Date.parse(value.last_attempt_at))) + || (value.last_outcome !== null && !OUTCOMES.includes(value.last_outcome)) + || !Number.isSafeInteger(value.attempted_through) || value.attempted_through < 0) return emptyDelivery(); + return value; + } catch { + return emptyDelivery(); + } +} + +function writeDelivery(options, state) { + const path = deliveryStatePath(options); + ensureSafeDir(dirname(path)); + const temporary = join(dirname(path), `.runtime-errors-delivery-${process.pid}-${randomBytes(6).toString('hex')}`); + try { + writeFileSync(temporary, `${JSON.stringify(state)}\n`, { mode: 0o600, flag: 'wx' }); + renameSync(temporary, path); + } finally { + rmSync(temporary, { force: true }); + } +} + +const nowMs = (options) => (options.now ? new Date(options.now).valueOf() : Date.now()); + +/** + * 自動送信してよい時機か。故障を記録した直後・解決を打った直後・以後のCLI実行が呼ぶ。 + * 未受領が無い、直前の試みから1分未満、同じ中身の送り直しが1時間未満、のどれかなら送らない。 + */ +function throttleReason(options, delivery, highWatermark) { + if (delivery.last_attempt_at === null) return null; + const elapsed = nowMs(options) - Date.parse(delivery.last_attempt_at); + if (elapsed < MIN_ATTEMPT_INTERVAL_MS) return 'attempted_within_a_minute'; + if (delivery.last_outcome !== 'delivered' && delivery.attempted_through === highWatermark + && elapsed < RETRY_INTERVAL_MS) return 'same_report_retried_within_an_hour'; + return null; +} + +export function runtimeErrorReportingStatus(options = {}) { + const supported = runtimeErrorCollectionSupported(options); + const enabled = runtimeErrorReportingEnabled(options); + const credential = supported ? readProductCredential(options) : { status: 'missing' }; + const diagnostics = runtimeErrorsDiagnostics(options); + const delivery = supported ? readDelivery(options) : emptyDelivery(); + return { + schema: STATUS_SCHEMA, + reporting: !supported ? 'unsupported' : enabled ? 'enabled' : 'disabled', + credential: credential.status === 'ok' ? 'present' : credential.status, + credential_reason: credential.status === 'unsafe' ? credential.reason : null, + collection: diagnostics.collection, + store_status: diagnostics.status, + pending_count: diagnostics.pending_count, + last_attempt_at: delivery.last_attempt_at, + last_outcome: delivery.last_outcome, + }; +} + +/** 送信の設定をLattice自身のfileへ書く。dotagentsの設定は読みも書きもしない。 */ +export function setRuntimeErrorReporting(enabled, options = {}) { + if (typeof enabled !== 'boolean') throw Error('invalid_enabled'); + if (!runtimeErrorCollectionSupported(options)) throw Error('reporting_unsupported'); + const env = options.env ?? process.env; + const path = options.reportingConfigPath ?? runtimeErrorReportingConfigPath(env); + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + try { + if (lstatSync(path).isSymbolicLink()) throw Error('config_unsafe'); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + const temporary = join(dirname(path), `.runtime-error-reporting-${process.pid}-${randomBytes(6).toString('hex')}`); + try { + writeFileSync(temporary, `${JSON.stringify({ schema: REPORTING_CONFIG_SCHEMA, enabled })}\n`, { mode: 0o600, flag: 'wx' }); + renameSync(temporary, path); + } finally { + rmSync(temporary, { force: true }); + } + return runtimeErrorReportingStatus(options); +} + +function post(url, headers, bodyBytes, timeoutMs) { + return new Promise((resolve) => { + const client = url.protocol === 'https:' ? https : http; + const request = client.request(url, { method: 'POST', headers, timeout: timeoutMs }, (response) => { + const chunks = []; + let size = 0; + response.on('data', (chunk) => { + size += chunk.length; + if (size > RESPONSE_MAX_BYTES) { request.destroy(); resolve({ status: null, reason: 'response_too_large' }); return; } + chunks.push(chunk); + }); + response.on('end', () => resolve({ status: response.statusCode ?? null, body: Buffer.concat(chunks).toString('utf8') })); + response.on('error', () => resolve({ status: null, reason: 'response_error' })); + }); + request.on('timeout', () => { request.destroy(); resolve({ status: null, reason: 'timeout' }); }); + request.on('error', () => resolve({ status: null, reason: 'network_error' })); + request.end(bodyBytes); + }); +} + +const result = (outcome, extra = {}) => ({ + schema: RESULT_SCHEMA, outcome, reason: null, http_status: null, + sent: { runtime_errors: 0, resolutions: 0 }, acknowledged_through: null, ...extra, +}); + +function parseJson(text) { + try { + const value = JSON.parse(text); + return plain(value) ? value : null; + } catch { + return null; + } +} + +/** + * 未受領の記録を1回送る。`options.auto`の時だけ送る時機の制限を見る(手で打った`report`は見ない + * ——多すぎればBugHubが429で断る)。戻り値に秘密・key_id・宛先は入れない。 + */ +export async function reportRuntimeErrors(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 }); + + let pending; + let delivery; + try { + const diagnostics = runtimeErrorsDiagnostics(options); + if (diagnostics.status !== 'ready') return result('store_unavailable'); + pending = runtimeErrorsSnapshot(diagnostics.acknowledged_through, REPORT_MAX_RECORDS, options); + delivery = readDelivery(options); + } catch { + return result('store_unavailable'); + } + const sent = { runtime_errors: pending.runtime_errors.length, resolutions: pending.resolutions.length }; + if (sent.runtime_errors + sent.resolutions === 0) { + return result('nothing_pending', { acknowledged_through: pending.cursor.acknowledged_through }); + } + if (options.auto === true) { + const reason = throttleReason(options, delivery, pending.cursor.high_watermark); + 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(), + last_outcome: outcome, attempted_through: pending.cursor.high_watermark }); + } catch { /* 送信の結果は返す。時機の記録だけが残らない。 */ } + 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 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 }); +} + +/** + * 自動送信の入口が、子processを起こす前に見る軽い判定。設定が無い端末(外の利用者)では、 + * 設定fileの有無を1回見るだけで終わる。 + */ +export function runtimeErrorAutoReportDue(options = {}) { + if (!runtimeErrorReportingEnabled(options)) return false; + if (readProductCredential(options).status !== 'ok') return false; + try { + const diagnostics = runtimeErrorsDiagnostics(options); + if (diagnostics.status !== 'ready' || diagnostics.pending_count === 0) return false; + return throttleReason(options, readDelivery(options), diagnostics.high_watermark) === null; + } catch { + return false; + } +} diff --git a/src/runtime-errors.mjs b/src/runtime-errors.mjs index 770c7539..3f4204ce 100644 --- a/src/runtime-errors.mjs +++ b/src/runtime-errors.mjs @@ -8,10 +8,10 @@ import { dirname, join } from 'node:path'; /** * opt-in runtime error store(親plan L6要件。Caveat `caveat.runtime_errors.v1` と同型の工場契約)。 * - * - collection/reporting分離: 収集可否は工場共有config - * `${XDG_CONFIG_HOME:-~/.config}/dotagents/factory-reporter.json` の `collection.enabled` だけが決める。 - * reporting設定はconfig validationの一部だが、本storeは外部送信を一切行わない(送信はdotagents adapter所有)。 - * - 既定OFF: config欠落・malformed・disabledでは state も network も触らない。 + * - 収集の有効化: 工場共有config `${XDG_CONFIG_HOME:-~/.config}/dotagents/factory-reporter.json` の + * `collection.enabled`、またはLattice自身の送信設定(`runtime-error-reporting.json`、ADR 0193)のどちらかが + * 有効な時だけ収集する。本storeは外部送信を行わない——送信は`runtime-error-reporting.mjs`が持つ。 + * - 既定OFF: どちらの設定も無い・malformed・disabledでは state も network も触らない。 * - privacy by design: 保存するのは固定catalogの `error_code` / `message_template` のみ。 * 生message・path・引数を保存しない。 * - retention: fingerprint集約(同一原因はcount/last_seen更新)+ack済みresolvedの30日compact。 @@ -21,6 +21,7 @@ import { dirname, join } from 'node:path'; const RUNTIME_ERRORS_SCHEMA = 'lattice.runtime_errors.v1'; const DIAGNOSTICS_SCHEMA = 'lattice.runtime_error_diagnostics.v1'; +export const REPORTING_CONFIG_SCHEMA = 'lattice.runtime_error_reporting_config.v1'; const PRODUCT = 'lattice'; const STATE_VERSION = '1.0'; const MAX_RECORDS = 256; @@ -82,7 +83,41 @@ function canonicalReporting(value) { const collectionSupported = (options = {}) => (options.platform ?? hostPlatform()) !== 'win32'; const inactiveCollection = (options = {}) => (collectionSupported(options) ? 'disabled' : 'unsupported'); +export function runtimeErrorCollectionSupported(options = {}) { + return collectionSupported(options); +} + +export function runtimeErrorReportingConfigPath(env = process.env) { + const home = env.HOME || homedir(); + return join(env.XDG_CONFIG_HOME || join(home, '.config'), 'lattice', 'runtime-error-reporting.json'); +} + +/** + * Lattice自身の送信設定(ADR 0193)。端末で`runtime-errors reporting enable`を打った時だけ有効になる。 + * `LATTICE_RUNTIME_ERROR_REPORTING=0`は既定の置き場を読まない——試験と自動化が、その端末の本物の設定を + * 拾って送信しないための口。`options.reportingConfigPath`を明示した呼び出しはそのfileを読む。 + */ +export function runtimeErrorReportingEnabled(options = {}) { + if (!collectionSupported(options)) return false; + const env = options.env ?? process.env; + if (options.reportingConfigPath === undefined && env.LATTICE_RUNTIME_ERROR_REPORTING === '0') return false; + try { + const path = options.reportingConfigPath ?? runtimeErrorReportingConfigPath(env); + const stats = lstatSync(path); + if (!stats.isFile() || stats.isSymbolicLink()) return false; + const config = JSON.parse(readFileSync(path, 'utf8')); + return plain(config) && exact(config, ['schema', 'enabled']) + && config.schema === REPORTING_CONFIG_SCHEMA && config.enabled === true; + } catch { + return false; + } +} + function collectionEnabled(options = {}) { + return factoryCollectionEnabled(options) || runtimeErrorReportingEnabled(options); +} + +function factoryCollectionEnabled(options = {}) { if (!collectionSupported(options)) return false; const env = options.env ?? process.env; try { @@ -140,7 +175,7 @@ function assertPosix(info, mode) { if ((info.mode & 0o777) !== mode || (typeof process.getuid === 'function' && info.uid !== process.getuid())) throw Error('store_unsafe'); } -function ensureSafeDir(dir) { +export function ensureSafeDir(dir) { if (hostPlatform() === 'win32') throw Error('store_unsafe'); mkdirSync(dir, { recursive: true, mode: 0o700 }); const stats = lstatSync(dir); @@ -148,7 +183,7 @@ function ensureSafeDir(dir) { assertPosix(stats, 0o700); } -function ensureSafeFile(path) { +export function ensureSafeFile(path) { const stats = lstatSync(path); if (!stats.isFile() || stats.isSymbolicLink()) throw Error('store_unsafe'); assertPosix(statSync(path), 0o600); diff --git a/test/runtime-error-reporting.test.mjs b/test/runtime-error-reporting.test.mjs new file mode 100644 index 00000000..bca96692 --- /dev/null +++ b/test/runtime-error-reporting.test.mjs @@ -0,0 +1,354 @@ +import assert from 'node:assert/strict'; +import { 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'; +import os from 'node:os'; +import path from 'node:path'; +import nodeTest from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { + recordRuntimeError, + runtimeErrorSafeContext, + runtimeErrorsDiagnostics, + runtimeErrorsSnapshot, + setRuntimeErrorStatus, +} from '../src/runtime-errors.mjs'; +import { + receiptSignatureMatches, + reportRuntimeErrors, + runtimeErrorAutoReportDue, + runtimeErrorReportingStatus, + setRuntimeErrorReporting, + signReport, +} from '../src/runtime-error-reporting.mjs'; + +// storeはPOSIX専用(Windowsは`unsupported`)。送信の試験はPOSIXだけで走らせ、署名と対象外の答えはどのOSでも確かめる。 +const posixTest = process.platform === 'win32' ? nodeTest.skip : nodeTest; +const cliPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'lattice.mjs'); +const CLI_FAILED = 'LATTICE.CLI_INTERNAL_FAILED'; +const SECRET = 'bughub-test-secret-do-not-use-0123456789abcdef'; +const KEY_ID = 'test-host:lattice.1'; +const hmac = (text) => createHmac('sha256', Buffer.from(SECRET, 'utf8')).update(text, 'utf8').digest('hex'); + +nodeTest('署名と応答の署名は、BugHubの契約の試験値と一致する', () => { + const body = Buffer.from('{"schema_version":"1.0","report_id":"00000000-0000-4000-8000-000000000001","product_id":"caveat","installed_version":"0.19.13","observed_at":"2026-09-21T14:13:20.000Z","runtime_errors":[],"resolutions":[]}'); + assert.equal(body.length, 205); + assert.equal(createHash('sha256').update(body).digest('hex'), '6a8ae99ecebde0f6d50b8e8d5273604c6e02c3df150645bd96de0e79d7710b2f'); + assert.equal(signReport(SECRET, '1790000000', body), 'e4ba0355c9d9058286a82d9622747eae264f780aa575e74859c40a6e529fa73a'); + const reportId = '00000000-0000-4000-8000-000000000001'; + const receivedAt = '2026-09-21T14:13:21.000Z'; + const sig = 'cc4ebb409cdd6a2be5f69f6acf8ebcd7f18acbe14b9ac82836797572f74a64bb'; + assert.equal(receiptSignatureMatches(SECRET, reportId, receivedAt, sig), true); + assert.equal(receiptSignatureMatches(SECRET, reportId, '2026-09-21T14:13:22.000Z', sig), false); + assert.equal(receiptSignatureMatches(SECRET, reportId, receivedAt, 'not-hex'), false); + assert.equal(receiptSignatureMatches(SECRET, reportId, undefined, sig), false); +}); + +nodeTest('収集に対応しないOSでは、送信も対象外と答え、設定を書かない', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'lattice-rterr-report-win-')); + try { + const options = { platform: 'win32', reportingConfigPath: path.join(root, 'reporting.json'), + credentialPath: path.join(root, 'lattice.json'), storePath: path.join(root, 'state', 'runtime-errors.json') }; + assert.equal((await reportRuntimeErrors(options)).outcome, 'unsupported'); + assert.equal(runtimeErrorReportingStatus(options).reporting, 'unsupported'); + assert.throws(() => setRuntimeErrorReporting(true, options), /reporting_unsupported/); + assert.equal(runtimeErrorAutoReportDue(options), false); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +/** 受け口の代わり。届いた要求を溜め、`respond`が返す応答をそのまま返す。 */ +async function startIntake(respond) { + const requests = []; + const server = http.createServer((request, response) => { + const chunks = []; + request.on('data', (chunk) => chunks.push(chunk)); + request.on('end', () => { + const bytes = Buffer.concat(chunks); + const entry = { method: request.method, url: request.url, headers: request.headers, bytes, + body: JSON.parse(bytes.toString('utf8')) }; + requests.push(entry); + const answer = respond(entry, requests.length); + if (answer === null) return; // 応答しない(時間切れの試験) + response.writeHead(answer.status, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify(answer.body)); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + return { requests, url: `http://127.0.0.1:${server.address().port}/api/products/v1/runtime-errors`, + close: () => new Promise((resolve) => { server.closeAllConnections(); server.close(resolve); }) }; +} + +const signedReceipt = (entry, extra = {}) => { + const receivedAt = '2026-10-03T09:00:01.000Z'; + return { status: 200, body: { accepted: true, report_id: entry.body.report_id, duplicate: false, + received_at: receivedAt, sig: hmac(`${entry.body.report_id}\n${receivedAt}`), ...extra } }; +}; + +async function makeWorkspace(intakeUrl, { enabled = true, credential = true } = {}) { + const root = await mkdtemp(path.join(os.tmpdir(), 'lattice-rterr-report-')); + const reportingConfigPath = path.join(root, 'config', 'runtime-error-reporting.json'); + const credentialPath = path.join(root, 'credentials', 'lattice.json'); + const options = { reportingConfigPath, credentialPath, version: '0.74.0', + configPath: path.join(root, 'config', 'factory-reporter.json'), + storePath: path.join(root, 'state', 'runtime-errors.json') }; + if (enabled) setRuntimeErrorReporting(true, options); + if (credential) { + await mkdir(path.dirname(credentialPath), { recursive: true }); + await writeFile(credentialPath, JSON.stringify({ url: intakeUrl, key_id: KEY_ID, secret: SECRET }), { mode: 0o600 }); + } + return { root, options, credentialPath }; +} + +const fail = (options, commandKind, code) => recordRuntimeError(CLI_FAILED, { ...options, + safeContext: runtimeErrorSafeContext({ commandKind, error: Object.assign(new Error('x'), { code }) }) }); + +posixTest('既定では通信しない: 送信を有効にしていない端末と、合鍵の無い端末は送らない', async () => { + const intake = await startIntake(signedReceipt); + const disabled = await makeWorkspace(intake.url, { enabled: false }); + const noCredential = await makeWorkspace(intake.url, { credential: false }); + try { + // 送信が無効で工場の設定も無ければ、収集そのものが無効で、記録も作らない。 + assert.deepEqual(recordRuntimeError(CLI_FAILED, disabled.options), { status: 'disabled' }); + assert.equal((await reportRuntimeErrors(disabled.options)).outcome, 'disabled'); + assert.equal(runtimeErrorAutoReportDue(disabled.options), false); + + // 送信を有効にすると収集も有効になる(dotagentsの設定は要らない)。合鍵が無ければ送らない。 + fail(noCredential.options, 'run.list', 'ENOENT'); + assert.equal(runtimeErrorsDiagnostics(noCredential.options).collection, 'enabled'); + assert.equal((await reportRuntimeErrors(noCredential.options)).outcome, 'credential_missing'); + assert.equal(runtimeErrorAutoReportDue(noCredential.options), false); + assert.equal(runtimeErrorReportingStatus(noCredential.options).credential, 'missing'); + assert.equal(intake.requests.length, 0); + } finally { + await intake.close(); + await rm(disabled.root, { recursive: true, force: true }); + await rm(noCredential.root, { recursive: true, force: true }); + } +}); + +posixTest('合鍵のfileは、本人所有・0600・symlinkでない形だけを使う', async () => { + const intake = await startIntake(signedReceipt); + const workspace = await makeWorkspace(intake.url); + try { + fail(workspace.options, 'run.list', 'ENOENT'); + await chmod(workspace.credentialPath, 0o644); + assert.deepEqual([(await reportRuntimeErrors(workspace.options)).outcome, + runtimeErrorReportingStatus(workspace.options).credential_reason], ['credential_unsafe', 'mode_not_0600']); + + await chmod(workspace.credentialPath, 0o600); + const linked = path.join(workspace.root, 'credentials', 'linked.json'); + await symlink(workspace.credentialPath, linked); + assert.equal((await reportRuntimeErrors({ ...workspace.options, credentialPath: linked })).reason, 'not_regular_file'); + + for (const broken of [ + { url: intake.url, key_id: KEY_ID, secret: 'short' }, + { url: intake.url, key_id: 'has space', secret: SECRET }, + { url: 'ftp://192.168.1.2/x', key_id: KEY_ID, secret: SECRET }, + { url: intake.url, key_id: KEY_ID, secret: SECRET, extra: 1 }, + ]) { + await writeFile(workspace.credentialPath, JSON.stringify(broken), { mode: 0o600 }); + assert.equal((await reportRuntimeErrors(workspace.options)).outcome, 'credential_unsafe', JSON.stringify(Object.keys(broken))); + } + assert.equal(intake.requests.length, 0); + } finally { + await intake.close(); + await rm(workspace.root, { recursive: true, force: true }); + } +}); + +posixTest('未受領の記録を署名つきで送り、署名つきの受領でだけ受領済みにする', async () => { + const intake = await startIntake(signedReceipt); + const workspace = await makeWorkspace(intake.url); + try { + fail(workspace.options, 'todo.status', 'ENOTDIR'); + fail(workspace.options, 'run.list', 'ENOENT'); + assert.equal(runtimeErrorAutoReportDue(workspace.options), true); + + const now = '2026-10-03T09:00:00.000Z'; + const delivered = await reportRuntimeErrors({ ...workspace.options, now }); + assert.equal(delivered.outcome, 'delivered'); + assert.deepEqual(delivered.sent, { runtime_errors: 2, resolutions: 0 }); + assert.equal(delivered.acknowledged_through, 2); + assert.equal(runtimeErrorsDiagnostics(workspace.options).pending_count, 0); + + assert.equal(intake.requests.length, 1); + const [request] = intake.requests; + assert.equal(request.method, 'POST'); + assert.equal(request.url, '/api/products/v1/runtime-errors'); + assert.equal(request.headers['content-type'], 'application/json'); + // 秘密は載せない。載るのは、送ったバイト列と時刻への署名だけ。 + const ts = String(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(request.bytes.toString('utf8').includes(SECRET), false); + + // 本文は契約の7項目だけ。端末名・path・例外の本文は入らない。 + assert.deepEqual(Object.keys(request.body), ['schema_version', 'report_id', 'product_id', 'installed_version', + 'observed_at', 'runtime_errors', 'resolutions']); + assert.equal(request.body.schema_version, '1.0'); + assert.equal(request.body.product_id, 'lattice'); + assert.equal(request.body.installed_version, '0.74.0'); + assert.equal(request.body.observed_at, now); + assert.match(request.body.report_id, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + assert.deepEqual(request.body.runtime_errors, runtimeErrorsSnapshot(0, 256, workspace.options).runtime_errors); + assert.deepEqual(request.body.runtime_errors[0].safe_context, + { command_kind: 'todo.status', error_kind: 'Error', cause_code: 'ENOTDIR' }); + assert.equal(request.bytes.toString('utf8').includes(workspace.root), false); + assert.equal(JSON.stringify(delivered).includes(KEY_ID), false); + + // 送るものが無ければ通信しない。 + assert.equal((await reportRuntimeErrors(workspace.options)).outcome, 'nothing_pending'); + assert.equal(intake.requests.length, 1); + + // 解決は、解決の記録として届く。 + const fingerprint = request.body.runtime_errors[0].fingerprint; + setRuntimeErrorStatus(fingerprint, 'resolved', workspace.options); + const resolved = await reportRuntimeErrors(workspace.options); + assert.deepEqual([resolved.outcome, resolved.sent], ['delivered', { runtime_errors: 0, resolutions: 1 }]); + assert.deepEqual(Object.keys(intake.requests[1].body.resolutions[0]), ['fingerprint', 'resolved_at', 'reason_code']); + assert.equal(intake.requests[1].body.resolutions[0].fingerprint, fingerprint); + assert.notEqual(intake.requests[1].body.report_id, request.body.report_id); + } finally { + await intake.close(); + await rm(workspace.root, { recursive: true, force: true }); + } +}); + +posixTest('受領を確かめられない応答では受領済みにせず、後から今の累計を送り直す', 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) } }), + (entry) => signedReceipt({ body: { report_id: '00000000-0000-4000-8000-000000000009' } }), + (entry) => signedReceipt(entry, { accepted: false }), + () => ({ status: 503, body: { error: 'unavailable' } }), + () => ({ status: 401, body: { error: 'unauthorized' } }), + () => ({ status: 422, body: { error: 'invalid_report', violations: [{ code: 'x', at: '/y' }] } }), + () => ({ status: 429, body: { error: 'rate_limited' } }), + () => ({ status: 418, body: { error: 'Not A Code!' } }), + () => null, + ]; + const intake = await startIntake((entry, count) => answers[count - 1](entry)); + const workspace = await makeWorkspace(intake.url); + try { + fail(workspace.options, 'run.list', 'ENOENT'); + const outcomes = []; + for (let index = 0; index < answers.length; index += 1) { + const result = await reportRuntimeErrors({ ...workspace.options, timeoutMs: 300 }); + outcomes.push([result.outcome, result.reason, result.http_status]); + } + assert.deepEqual(outcomes, [ + ['unconfirmed', 'receipt_not_verified', 200], + ['unconfirmed', 'receipt_not_verified', 200], + ['unconfirmed', 'receipt_not_verified', 200], + ['unconfirmed', 'unavailable', 503], + ['rejected', 'unauthorized', 401], + ['rejected', 'invalid_report', 422], + ['rejected', 'rate_limited', 429], + ['rejected', 'unrecognized_response', 418], + ['unconfirmed', 'timeout', null], + ]); + // どの応答でも、記録は未受領のまま残る。 + assert.equal(runtimeErrorsDiagnostics(workspace.options).pending_count, 1); + assert.equal(runtimeErrorsDiagnostics(workspace.options).acknowledged_through, 0); + assert.equal(runtimeErrorReportingStatus(workspace.options).last_outcome, 'unconfirmed'); + // 送り直しは毎回新しいreport_idで、その時点の累計を送る。 + assert.equal(new Set(intake.requests.map((request) => request.body.report_id)).size, answers.length); + } finally { + await intake.close(); + await rm(workspace.root, { recursive: true, force: true }); + } +}); + +posixTest('自動送信は1分に1回まで、同じ中身の送り直しは1時間に1回まで', async () => { + let accept = false; + const intake = await startIntake((entry) => (accept ? signedReceipt(entry) : { status: 503, body: { error: 'unavailable' } })); + const workspace = await makeWorkspace(intake.url); + const at = (minutes) => ({ ...workspace.options, auto: true, now: new Date(Date.parse('2026-10-03T09:00:00.000Z') + minutes * 60_000).toISOString() }); + try { + fail(workspace.options, 'run.list', 'ENOENT'); + assert.equal((await reportRuntimeErrors(at(0))).outcome, 'unconfirmed'); + assert.deepEqual([(await reportRuntimeErrors(at(0.5))).outcome, (await reportRuntimeErrors(at(0.5))).reason], + ['throttled', 'attempted_within_a_minute']); + assert.equal((await reportRuntimeErrors(at(30))).reason, 'same_report_retried_within_an_hour'); + assert.equal(runtimeErrorAutoReportDue(at(30)), false); + assert.equal(intake.requests.length, 1); + + // 新しい記録が増えたら、1時間を待たずに送る。 + fail(workspace.options, 'todo.status', 'ENOTDIR'); + assert.equal(runtimeErrorAutoReportDue(at(31)), true); + assert.equal((await reportRuntimeErrors(at(31))).outcome, 'unconfirmed'); + assert.equal((await reportRuntimeErrors(at(60))).outcome, 'throttled'); + + // 1時間たてば、同じ中身でも送り直す。受領されれば未受領は無くなる。 + accept = true; + const delivered = await reportRuntimeErrors(at(92)); + assert.deepEqual([delivered.outcome, delivered.sent.runtime_errors], ['delivered', 2]); + assert.equal(runtimeErrorAutoReportDue(at(200)), false); + assert.equal(intake.requests.length, 3); + // 手で打った送信は時機の制限を見ない。 + fail(workspace.options, 'run.status', 'EACCES'); + assert.equal((await reportRuntimeErrors({ ...workspace.options, now: at(92.1).now })).outcome, 'delivered'); + } finally { + await intake.close(); + await rm(workspace.root, { recursive: true, force: true }); + } +}); + +posixTest('CLI: 送信を有効にした端末では、故障を記録した直後に切り離した子processが送る', async () => { + const intake = await startIntake(signedReceipt); + const root = await mkdtemp(path.join(os.tmpdir(), 'lattice-rterr-report-cli-')); + try { + const env = { ...process.env, NO_COLOR: '1', HOME: root, LATTICE_DASHBOARD_AUTOSTART: '0', + XDG_CONFIG_HOME: path.join(root, '.config'), XDG_STATE_HOME: path.join(root, 'xdg-state') }; + delete env.LATTICE_RUNTIME_ERROR_REPORTING; + const cli = (args, cwd = root) => spawnSync(process.execPath, [cliPath, ...args], { cwd, encoding: 'utf8', env }); + const json = (result) => JSON.parse(result.stdout.trim().split('\n').at(-1)); + + // 既定は無効。有効にする前は、故障しても記録も通信もしない。 + assert.equal(json(cli(['runtime-errors', 'reporting', 'status', '--json'])).reporting, 'disabled'); + const repo = path.join(root, 'repo'); + await mkdir(repo); + assert.equal(spawnSync('git', ['init', '--quiet'], { cwd: repo }).status, 0); + await writeFile(path.join(repo, '.lattice'), 'not a directory\n'); + assert.equal(cli(['todo', 'status', '--json'], repo).status, 1); + assert.equal(json(cli(['runtime-errors', 'diagnostics', '--json'])).collection, 'disabled'); + + const credentialDir = path.join(root, '.config', 'bughub', 'product-credentials'); + await mkdir(credentialDir, { recursive: true }); + await writeFile(path.join(credentialDir, 'lattice.json'), + JSON.stringify({ url: intake.url, key_id: KEY_ID, secret: SECRET }), { mode: 0o600 }); + const enabled = json(cli(['runtime-errors', 'reporting', 'enable', '--json'])); + assert.deepEqual([enabled.reporting, enabled.credential, enabled.collection], ['enabled', 'present', 'enabled']); + assert.equal((await readFile(path.join(root, '.config', 'lattice', 'runtime-error-reporting.json'), 'utf8')).trim(), + '{"schema":"lattice.runtime_error_reporting_config.v1","enabled":true}'); + assert.equal(intake.requests.length, 0); + + // 故障を1件起こす。CLIは送信を待たずに返り、子processが届ける。 + const failed = cli(['todo', 'status', '--json'], repo); + assert.equal(failed.status, 1, failed.stderr); + const deadline = Date.now() + 20_000; + while (Date.now() < deadline + && json(cli(['runtime-errors', 'reporting', 'status', '--json'])).last_outcome !== 'delivered') { + await new Promise((resolve) => setTimeout(resolve, 200)); + } + const status = json(cli(['runtime-errors', 'reporting', 'status', '--json'])); + assert.deepEqual([status.last_outcome, status.pending_count], ['delivered', 0]); + assert.equal(intake.requests.length, 1); + assert.deepEqual(intake.requests[0].body.runtime_errors[0].safe_context, + { command_kind: 'todo.status', error_kind: 'Error', cause_code: 'ENOTDIR' }); + + // 手で打つ送信: 送るものが無ければ通信せずexit 0。無効に戻せば送らない。 + const manual = cli(['runtime-errors', 'report', '--json']); + assert.deepEqual([manual.status, json(manual).outcome], [0, 'nothing_pending']); + 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); + } finally { + await intake.close(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/test/runtime-error-safe-context.test.mjs b/test/runtime-error-safe-context.test.mjs index 625f3efa..3773f15e 100644 --- a/test/runtime-error-safe-context.test.mjs +++ b/test/runtime-error-safe-context.test.mjs @@ -35,7 +35,8 @@ async function makeWorkspace() { const storePath = path.join(root, 'state', 'runtime-errors.json'); await mkdir(path.dirname(configPath), { recursive: true }); await writeFile(configPath, JSON.stringify(VALID_CONFIG)); - return { root, storePath, options: { configPath, storePath, version: '0.72.0' } }; + const reportingConfigPath = path.join(root, 'config', 'runtime-error-reporting.json'); + return { root, storePath, options: { configPath, storePath, reportingConfigPath, version: '0.72.0' } }; } test('分類は固定語彙だけを通し、語彙に無い値はother/noneへ落とす', () => { diff --git a/test/runtime-errors.test.mjs b/test/runtime-errors.test.mjs index 2d0c3906..5328af3b 100644 --- a/test/runtime-errors.test.mjs +++ b/test/runtime-errors.test.mjs @@ -33,6 +33,8 @@ const VALID_CONFIG = { reporting: { enabled: false }, }; +const isolatedEnv = { ...process.env, LATTICE_RUNTIME_ERROR_REPORTING: '0' }; + async function makeWorkspace(config = VALID_CONFIG) { const root = await mkdtemp(path.join(os.tmpdir(), 'lattice-rterr-')); const configPath = path.join(root, 'config', 'factory-reporter.json'); @@ -41,13 +43,15 @@ async function makeWorkspace(config = VALID_CONFIG) { await mkdir(path.dirname(configPath), { recursive: true }); await writeFile(configPath, JSON.stringify(config)); } - return { root, configPath, storePath, options: { configPath, storePath, version: '0.1.0' } }; + // Lattice自身の送信設定は、この端末の本物のfileでなく、存在しないfileを読ませる。 + const reportingConfigPath = path.join(root, 'config', 'runtime-error-reporting.json'); + return { root, configPath, storePath, options: { configPath, storePath, reportingConfigPath, version: '0.1.0' } }; } test('config欠落・malformed・disabledでは収集せずstateへ一切触れない', async () => { const missing = await makeWorkspace(null); try { - assert.equal(runtimeCollectionEnabled(process.env, missing.configPath), false); + assert.equal(runtimeCollectionEnabled(isolatedEnv, missing.configPath), false); assert.deepEqual(recordRuntimeError('LATTICE.CLI_INTERNAL_FAILED', missing.options), { status: 'disabled' }); const snapshot = runtimeErrorsSnapshot(0, 256, missing.options); assert.equal(snapshot.diagnostics.collection, 'disabled'); @@ -78,7 +82,7 @@ test('工場が名乗るhost profileはすべて収集を有効にし、未知 for (const profile of ['server', 'mac', 'linux', 'wsl', 'windows-native']) { const workspace = await makeWorkspace({ ...VALID_CONFIG, host: { id: 'test-host', profile } }); try { - assert.equal(runtimeCollectionEnabled(process.env, workspace.configPath), true, profile); + assert.equal(runtimeCollectionEnabled(isolatedEnv, workspace.configPath), true, profile); assert.equal(runtimeErrorsDiagnostics(workspace.options).collection, 'enabled', profile); } finally { await rm(workspace.root, { recursive: true, force: true }); @@ -86,7 +90,7 @@ test('工場が名乗るhost profileはすべて収集を有効にし、未知 } const unknown = await makeWorkspace({ ...VALID_CONFIG, host: { id: 'test-host', profile: 'linux-native' } }); try { - assert.equal(runtimeCollectionEnabled(process.env, unknown.configPath), false); + assert.equal(runtimeCollectionEnabled(isolatedEnv, unknown.configPath), false); } finally { await rm(unknown.root, { recursive: true, force: true }); }