From fe44cc8797ddf8ae8ddfef7aa1317eb747a193c8 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:15:35 +0000 Subject: [PATCH 1/8] Add opt-in Spotter runtime error reporting to BugHub --- CHANGELOG.md | 1 + README.ja.md | 22 +- README.md | 40 +++- bin/spotter.mjs | 6 + docs/02_spotter-claude-contract.md | 15 +- docs/open-issues.md | 11 + .../dev.spotter.runtime-errors.plist | 10 + .../spotter-runtime-errors.service | 6 + .../spotter-runtime-errors.timer | 10 + package.json | 1 + src/cli/runtime-error-report-cmd.mjs | 16 ++ src/core/runtime-error-reporter.mjs | 196 ++++++++++++++++++ src/core/runtime-error-store-worker.mjs | 5 +- src/core/runtime-error-store.mjs | 13 +- src/core/runtime-report-config.mjs | 58 ++++++ src/platform/owner-private-file.mjs | 80 +++++++ test/runtime-error-reporter.test.mjs | 177 ++++++++++++++++ 17 files changed, 648 insertions(+), 19 deletions(-) create mode 100644 ops/runtime-reporting/dev.spotter.runtime-errors.plist create mode 100644 ops/runtime-reporting/spotter-runtime-errors.service create mode 100644 ops/runtime-reporting/spotter-runtime-errors.timer create mode 100644 src/cli/runtime-error-report-cmd.mjs create mode 100644 src/core/runtime-error-reporter.mjs create mode 100644 src/core/runtime-report-config.mjs create mode 100644 src/platform/owner-private-file.mjs create mode 100644 test/runtime-error-reporter.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index c1d7856..b63ed58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ## Unreleased +- Spotter自身が端末内runtime-errorの累計をBugHubへ送るopt-in CLIを追加。署名済み受領だけでackし、既定では通信しない。 - Jev監査の大きなtool catalogを入力上限内のリクエストへ分け、全候補の判定とtoken usageを統合する。 上限超過のHTTP 400で監査が止まる問題を修正し、途中の失敗は部分的なpassへ変換しない。 diff --git a/README.ja.md b/README.ja.md index d2d6639..117f25a 100644 --- a/README.ja.md +++ b/README.ja.md @@ -22,7 +22,7 @@ project marker、catalog discoveryとhost-local tool DB、評価store、dashboard server、 diagnostics、installer、release packagingを所有します。 [dotagents](https://github.com/kitepon/dotagents)が所有するのは共有agent指示と、 -Spotterの端末内runtime-error集計を有効化する任意のfactory-reporter設定です。 +旧来の端末内runtime-error集計に使える任意のfactory-reporter設定です。実行時エラーの送信はSpotterが所有します。 Spotterのcatalogやhost統合はdotagentsの責務ではありません。MarkItDownは別区分の第三者CLIです。 Claude には「使えるツールがあるのに、使うべきタイミングで使わない」という構造的な弱点があります。記録すべき決定を memory / caveat MCP に残さない、docs lookup MCP を呼ばずに古い知識で応答する、ブラウザ自動化 MCP で確認せず UI 状態を推測する — **「分からないと自覚できない」から、ツールを取りに行けない**。 @@ -290,9 +290,10 @@ dashboardの2つのPowerShell actionを非対話・console非表示で起動す ## 端末内runtime error集計 -factory diagnosticsとruntime error集計は既定OFFです。canonicalなdotagents factory reporter設定で -JSON booleanの`collection.enabled: true`が明示された場合だけ、固定codeの失敗を端末内へ集計します。 -Spotterはreporting credentialもnetwork送信経路も持ちません。保存APIは固定templateとallow-list済み集計だけを受け付け、 +factory diagnosticsとruntime error集計は既定OFFです。Spotter専用設定で +`collection.enabled: true`を明示した場合、固定codeの失敗を端末内へ集計します。この設定が無い端末では、 +旧dotagents factory reporter設定を収集だけの互換入力として読みます。送信はSpotter専用設定の +`reporting.enabled: true`で別に明示した時だけ行い、既定では通信しません。保存APIは固定templateとallow-list済み集計だけを受け付け、 例外本文、stdout/stderr、stack、prompt、hook payload、finding、ファイル内容、絶対pathを保存しません。 daemonとCodex hookの収集境界は、bounded timeout付きのkill可能なchild process groupで実行します。 @@ -305,6 +306,19 @@ readbackを検証します。 受理後のlifecycle操作です。`spotter diagnostics logs`と`spotter diagnostics factory`はboundedな件数と statusだけを返し、store/config pathやrecord本文を出しません。 +指揮官の端末で送信を有効にする場合、所有者だけが読める通常ファイル(0600)を +`~/.config/spotter/runtime-errors.json`(Windowsは`%LOCALAPPDATA%\Spotter\runtime-errors-config.json`) +に置きます。内容は`{"schema_version":"1.0","collection":{"enabled":true},"reporting":{"enabled":true}}`です。 +BugHub担当が別途、端末ごとの合鍵を`~/.config/bughub/product-credentials/spotter.json` +(Windowsは`%LOCALAPPDATA%\bughub\product-credentials\spotter.json`)へ置きます。 +`spotter runtime-errors report`を実行し、継続時はlaunchd、systemd user timer、Windows Task Schedulerで +同じコマンドを1時間に1回起動します。未受領だけを送信し、署名を検証した200応答だけでackします。 +配布物の`ops/runtime-reporting/`にlaunchdとsystemd user timerの雛形があります。 +`REPLACE_WITH_ABSOLUTE_SPOTTER_PATH`を導入済みCLIの絶対pathへ替えてから登録します。 +Windowsでは同じ利用者のTask Schedulerへ`spotter.cmd runtime-errors report`を1時間ごとに登録します。 +422が返った版は再送を止め、修正版を導入すると再試行します。無効化は`reporting.enabled`を`false`にします。 +401/403で拒まれた合鍵は入れ替わるまで再送を止めます。時計ずれはackせず、時刻修正後に再試行します。 + Primary auditor backend policy: Claude hooks の auto selection は PATH に Codex CLI があれば Codex CLI、 なければ Haiku compatibility path。Codex native hooks の auto selection は Codex CLI です。 `SPOTTER_AUDITOR_BACKEND` の明示 override はどちらの host でも優先し、runtime failure では別 backend へ diff --git a/README.md b/README.md index be9f99b..cfb924a 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,8 @@ This repository owns the complete Spotter product surface: auditor behavior, Claude/Codex/Cursor/Grok hook adapters, project markers, catalog discovery and host-local tool databases, evaluation storage, dashboard servers, diagnostics, installers, and release packaging. [dotagents](https://github.com/kitepon/dotagents) -owns shared agent instructions and the optional factory-reporter configuration -that enables Spotter's local runtime-error aggregate; it does not own Spotter's +owns shared agent instructions and an optional legacy factory-reporter configuration +for local collection; Spotter owns its product runtime-error reporting; dotagents does not own Spotter's catalog or host integration. MarkItDown is a separate third-party CLI. Claude has a structural blind spot: **it can't reach for a tool it doesn't realize it needs**. It may skip a project memory MCP when a decision should be recorded, answer from stale memory instead of a docs-lookup MCP, or reason about UI state without a browser-automation MCP. The model can't always tell when it doesn't know — so the tool stays unused. @@ -305,17 +305,18 @@ otherwise the Haiku-compatible path. Codex native hooks automatically select Cod error aggregate は collection が既定OFFです。npm `latest`、tag / GitHub Release、 公開CI、registry由来installを確認済みです。 -Spotter collects fixed-code runtime failures only when the canonical dotagents factory reporter -configuration contains the JSON boolean `collection.enabled: true`. Missing, malformed, and disabled -configuration all fail closed. Collection is local-only: Spotter has no reporting credential or network -transport code. The owner-private atomic store contains only fixed templates and allow-listed aggregates; +Spotter collects fixed-code runtime failures when its owner-private product config contains +`collection.enabled: true`. If that config is absent, the old canonical dotagents factory reporter +config remains a collection-only compatibility input. Malformed or disabled product config fails closed. +Reporting is independently opt-in with `reporting.enabled: true`; the default makes no network request. +The owner-private atomic store contains only fixed templates and allow-listed aggregates; raw exceptions, stdout/stderr, stacks, prompts, hook payloads, findings, file contents, and absolute paths are not accepted by its API. Daemon and direct Codex-hook owner boundaries perform collection in a killable child-process group with -a bounded timeout. A blocked FIFO or descendant therefore cannot stall the hook or daemon. HTTP reporting -metadata, when present in the shared config, is accepted only when `new URL(value).href === value` and the -scheme is HTTP(S); Spotter still neither reads credentials nor sends the aggregate anywhere. +a bounded timeout. A blocked FIFO or descendant therefore cannot stall the hook or daemon. The product +report command reads only Spotter's opt-in config and the owner-private BugHub credential, never factory +reporting metadata. It sends only fixed aggregate fields to the configured owner-LAN BugHub endpoint. On POSIX, every config/store read revalidates the current uid and exact `0600` file / `0700` directory modes. Store mutations use a private SQLite `BEGIN IMMEDIATE` mutex that the OS releases on process crash; @@ -330,6 +331,27 @@ and `spotter diagnostics factory` include only bounded store counts/status, neve or record payload. Programmatic consumers can import `readRuntimeErrorSnapshot`, `acknowledgeRuntimeErrors`, `resolveRuntimeError`, `reopenRuntimeError`, and `compactRuntimeErrors`. Acknowledgement is monotonic, and compaction never removes an unacknowledged record. + +To enable owner-LAN reporting on one terminal, create the following `0600` owner-owned regular file +at `~/.config/spotter/runtime-errors.json` (Windows: `%LOCALAPPDATA%\Spotter\runtime-errors-config.json`): + +```json +{"schema_version":"1.0","collection":{"enabled":true},"reporting":{"enabled":true}} +``` + +BugHub's operator supplies a separate `0600` credential at +`~/.config/bughub/product-credentials/spotter.json` (Windows: +`%LOCALAPPDATA%\bughub\product-credentials\spotter.json`). Run `spotter runtime-errors report` +once, then schedule the same command hourly with launchd, a systemd user timer, or Windows Task Scheduler. +The package includes launchd and systemd templates in `ops/runtime-reporting/`; replace +`REPLACE_WITH_ABSOLUTE_SPOTTER_PATH` with the installed CLI path before registering them. On Windows, +register an hourly task for the installed `spotter.cmd runtime-errors report` under the same user account. +Only unacknowledged records are sent. A signed 200 response advances the cursor; timeout, 5xx, or an +unverified response leaves it pending. A 422 blocks further sends for that installed version until a +corrected Spotter version is installed; the block marker is under the private `~/.spotter` state directory +(Windows: `%LOCALAPPDATA%\Spotter`). A rejected 401/403 credential is paused until it is rotated; +clock-skew responses leave the cursor pending for a later attempt. Disable reporting by changing +`reporting.enabled` to `false`. The Codex SessionStart hook refreshes `.spotter/tool-db.codex.json` in the background without touching the Claude DB. Codex CLI auditor child processes use a versioned product policy. The production selection is diff --git a/bin/spotter.mjs b/bin/spotter.mjs index 2240b93..0530ebf 100755 --- a/bin/spotter.mjs +++ b/bin/spotter.mjs @@ -12,6 +12,7 @@ import { runCursorHookCommand } from '../src/cli/cursor-hook-cmd.mjs'; import { runGrokHookCommand } from '../src/cli/grok-hook-cmd.mjs'; import { runAuditorCommand } from '../src/cli/auditor-cmd.mjs'; import { runDiagnosticsCommand } from '../src/cli/diagnostics-cmd.mjs'; +import { runRuntimeErrorReportCommand } from '../src/cli/runtime-error-report-cmd.mjs'; import { runEvaluationCommand } from '../src/cli/evaluation-cmd.mjs'; import { runDashboardCommand } from '../src/cli/dashboard-cmd.mjs'; import { @@ -53,6 +54,7 @@ Usage: spotter diagnostics factory emit a fixed-field read-only JSON diagnostic spotter diagnostics runtime-errors [snapshot|ack|resolve|reopen|compact] consume the local allow-listed aggregate store + spotter runtime-errors report send pending aggregates to the owner LAN BugHub (opt-in) spotter evaluation report [--project PATH] [--from ISO] [--to ISO] [--host HOST] [--tool-id ID] [--backend NAME] [--model NAME] [--spotter-version VERSION] [--json] @@ -151,6 +153,10 @@ async function main() { } await runDiagnosticsCommand({ argv: rest }); return; + case 'runtime-errors': + if (rest[0] !== 'report') throw Object.assign(new Error('usage: spotter runtime-errors report'), { exitCode: 2 }); + await runRuntimeErrorReportCommand({ argv: rest.slice(1) }); + return; case 'evaluation': await runEvaluationCommand({ argv: rest }); return; diff --git a/docs/02_spotter-claude-contract.md b/docs/02_spotter-claude-contract.md index 9e38fb9..73b2290 100644 --- a/docs/02_spotter-claude-contract.md +++ b/docs/02_spotter-claude-contract.md @@ -51,6 +51,7 @@ Public CLI: - `spotter diagnostics factory` - `spotter diagnostics runtime-errors [snapshot [--after-cursor ] [--limit ] | ack | resolve | reopen | compact]` +- `spotter runtime-errors report` - `spotter evaluation report [--project ] [--from ] [--to ] [--host ] [--tool-id ] [--backend ] [--model ] [--spotter-version ] [--json]` - `spotter evaluation cases --outcome [--project ] [--from ] [--to ] @@ -316,9 +317,10 @@ existing Claude-facing `{pass, missing_tools, reason?}` shape. - `spotter diagnostics logs` is read-only. It parses daemon log files and reports `pass:false`, missing-tool counts, duration summaries, catalog-external drops, role-collapse resets, Haiku failures, and handler errors without changing daemon behavior. -- Runtime error collection is a separate local projection gated only by the canonical dotagents - reporter config's JSON boolean `collection.enabled: true`. Missing/malformed/disabled config does - not create or touch the store. The store performs no network I/O and accepts only fixed Spotter +- Runtime error collection is a separate local projection gated by Spotter's owner-private product + config `collection.enabled: true`. If absent, the canonical dotagents reporter config is a + collection-only compatibility input. Malformed/disabled product config fails closed. The store + performs no network I/O and accepts only fixed Spotter failure kinds; code, template, component, and severity come from a closed registry rather than exception/provider/hook input. Fingerprints use the factory-v1 canonical SHA-256 sequence. - The daemon owns transport and PID-state persistence observations. The daemon owns Claude primary @@ -339,6 +341,13 @@ existing Claude-facing `{pass, missing_tools, reason?}` shape. - `spotter diagnostics runtime-errors` is the read-only allow-listed snapshot. `diagnostics logs` and `diagnostics factory` expose only bounded collection/store status and counts. Cursor acknowledgement is monotonic; resolve/reopen advance sequence; compaction preserves all unacknowledged records. +- `spotter runtime-errors report` is an independent one-shot product sender. It requires Spotter's + product config to enable both collection and reporting. It reads the BugHub owner-private credential, + sends at most 500 pending records and 512 KiB to the fixed owner-LAN endpoint, and projects only the + contract's allow-listed fields. The request signs the exact body bytes with HMAC-SHA256. Only HTTP 200 + with `accepted:true`, matching `report_id`, and a valid response signature advances the cursor. 422 + blocks repeat sends by the same installed version. Rejected credentials pause until rotation; clock + skew does not acknowledge. Hourly OS scheduling is an operator opt-in. ## Evaluation Contract diff --git a/docs/open-issues.md b/docs/open-issues.md index 273ab82..93af9b3 100644 --- a/docs/open-issues.md +++ b/docs/open-issues.md @@ -10,6 +10,17 @@ Spotterの現在の未完事項だけを記録する。 - 解決した項目は本文へ残さず、CHANGELOGまたは完了計画へ移す。 - P0は現在のrollout判断、P1は次の運用窓、P2は既知だが未発生のplatform固有リスク。 +## P0 — BugHubへの製品報告rollout + +Spotter専用のopt-in送信CLIは実装中。BugHubの受け口と各端末の合鍵は担当者側で準備中で、 +Spotterのreleaseと4端末への適用にはオーナー承認が必要。既存のauditor修正PR #4も +版付きreleaseと4端末への導入まで台帳上は未解決。 + +### 次の行動 + +署名試験、製品CI、レビューを通してdraft PRを用意する。BugHubの受け口と合鍵が届いたら、 +オーナーの公開・適用承認後に4端末で送信を有効にし、端末・版・送信時刻・受領結果を報告する。 + ## P1 — Grok監査結果の会話内表示 Grok 1.0.41のnative `UserPromptSubmit` / `Stop` hookはstdoutを会話へ渡さない。 diff --git a/ops/runtime-reporting/dev.spotter.runtime-errors.plist b/ops/runtime-reporting/dev.spotter.runtime-errors.plist new file mode 100644 index 0000000..f519cfd --- /dev/null +++ b/ops/runtime-reporting/dev.spotter.runtime-errors.plist @@ -0,0 +1,10 @@ + + + + Labeldev.spotter.runtime-errors + ProgramArguments + REPLACE_WITH_ABSOLUTE_SPOTTER_PATH + runtime-errorsreport + + StartInterval3600 + diff --git a/ops/runtime-reporting/spotter-runtime-errors.service b/ops/runtime-reporting/spotter-runtime-errors.service new file mode 100644 index 0000000..41710b5 --- /dev/null +++ b/ops/runtime-reporting/spotter-runtime-errors.service @@ -0,0 +1,6 @@ +[Unit] +Description=Send pending Spotter runtime errors to owner-LAN BugHub + +[Service] +Type=oneshot +ExecStart=REPLACE_WITH_ABSOLUTE_SPOTTER_PATH runtime-errors report diff --git a/ops/runtime-reporting/spotter-runtime-errors.timer b/ops/runtime-reporting/spotter-runtime-errors.timer new file mode 100644 index 0000000..eaf6c83 --- /dev/null +++ b/ops/runtime-reporting/spotter-runtime-errors.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Report pending Spotter runtime errors hourly + +[Timer] +OnStartupSec=5m +OnUnitActiveSec=1h +Unit=spotter-runtime-errors.service + +[Install] +WantedBy=timers.target diff --git a/package.json b/package.json index aafe432..2c34b98 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "src", "scripts", "ops/dashboard", + "ops/runtime-reporting", "docs/11_dashboard-operations.md", "README.md", "LICENSE", diff --git a/src/cli/runtime-error-report-cmd.mjs b/src/cli/runtime-error-report-cmd.mjs new file mode 100644 index 0000000..77564b3 --- /dev/null +++ b/src/cli/runtime-error-report-cmd.mjs @@ -0,0 +1,16 @@ +import { reportRuntimeErrors } from '../core/runtime-error-reporter.mjs'; + +export async function runRuntimeErrorReportCommand({ argv = [], write = (line) => process.stdout.write(line) } = {}) { + if (argv.length !== 0) { + const error = new Error('usage: spotter runtime-errors report'); + error.exitCode = 2; + throw error; + } + let result; + try { result = await reportRuntimeErrors(); } + catch { + result = { status: 'report_unavailable' }; + } + write(`${JSON.stringify(result)}\n`); + if (!['accepted', 'nothing_to_report', 'disabled'].includes(result.status)) process.exitCode = 1; +} diff --git a/src/core/runtime-error-reporter.mjs b/src/core/runtime-error-reporter.mjs new file mode 100644 index 0000000..b0b9c11 --- /dev/null +++ b/src/core/runtime-error-reporter.mjs @@ -0,0 +1,196 @@ +import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto'; +import { dirname, join } from 'node:path'; +import { version } from '../version.mjs'; +import { readOwnerPrivateFile, writeOwnerPrivateFile } from '../platform/owner-private-file.mjs'; +import { + acknowledgeRuntimeErrors, + defaultRuntimeErrorStorePath, + readRuntimeErrorSnapshot, + readRuntimeErrorStoreStatus, +} from './runtime-error-store.mjs'; +import { readRuntimeReportConfig, readRuntimeReportCredential } from './runtime-report-config.mjs'; + +const MAX_BODY_BYTES = 512 * 1024; +const FIELDS = [ + 'fingerprint', 'error_code', 'component', 'message_template', 'severity', 'status', + 'occurrence_count', 'first_seen', 'last_seen', 'product_version', 'state_schema_version', +]; + +export function signRuntimeReport(secret, ts, bodyBytes) { + const digest = createHash('sha256').update(bodyBytes).digest('hex'); + return createHmac('sha256', Buffer.from(secret, 'utf8')).update(`${ts}\n${digest}`).digest('hex'); +} + +export function signRuntimeReportReceipt(secret, reportId, receivedAt) { + return createHmac('sha256', Buffer.from(secret, 'utf8')) + .update(`${reportId}\n${receivedAt}`).digest('hex'); +} + +function verifiedReceipt(value, reportId, secret) { + if (!value || value.accepted !== true || value.report_id !== reportId + || typeof value.received_at !== 'string' || typeof value.sig !== 'string' + || !/^[a-f0-9]{64}$/.test(value.sig)) return false; + const expected = signRuntimeReportReceipt(secret, reportId, value.received_at); + return timingSafeEqual(Buffer.from(value.sig, 'hex'), Buffer.from(expected, 'hex')); +} + +function project(record) { + const item = Object.fromEntries(FIELDS.filter((field) => record[field] !== undefined) + .map((field) => [field, record[field]])); + const resolution = record.status === 'resolved' + ? { fingerprint: record.fingerprint, resolved_at: record.resolved_at, reason_code: record.reason_code } + : null; + return { item, resolution }; +} + +function blockPath(storeOptions) { + return join(dirname(storeOptions.storePath ?? defaultRuntimeErrorStorePath(storeOptions)), 'runtime-report-blocked-v1.json'); +} + +function credentialBlockPath(storeOptions) { + return join(dirname(storeOptions.storePath ?? defaultRuntimeErrorStorePath(storeOptions)), 'runtime-report-credential-blocked-v1.json'); +} + +function credentialTag(credential) { + return createHash('sha256').update(`${credential.key_id}\n${credential.secret}`, 'utf8').digest('hex'); +} + +async function readBlockedCredential(storeOptions) { + try { + const value = JSON.parse(await readOwnerPrivateFile(credentialBlockPath(storeOptions), storeOptions)); + return value?.schema === 'spotter.runtime_report_credential_block.v1' && /^[a-f0-9]{64}$/.test(value.tag) + ? value.tag : null; + } catch (error) { + if (error?.code === 'ENOENT') return null; + throw error; + } +} + +async function writeBlockedCredential(storeOptions, tag) { + await writeOwnerPrivateFile( + credentialBlockPath(storeOptions), + JSON.stringify({ schema: 'spotter.runtime_report_credential_block.v1', tag }), + storeOptions, + ); +} + +async function responseCode(response) { + try { + const raw = await response.text(); + if (Buffer.byteLength(raw, 'utf8') > 16_384) return null; + const value = JSON.parse(raw); + return typeof value.code === 'string' ? value.code + : typeof value.error === 'string' ? value.error + : typeof value.error?.code === 'string' ? value.error.code : null; + } catch { return null; } +} + +async function readBlockedVersion(storeOptions) { + try { + const value = JSON.parse(await readOwnerPrivateFile(blockPath(storeOptions), storeOptions)); + return value?.schema === 'spotter.runtime_report_block.v1' && typeof value.version === 'string' + ? value.version : null; + } catch (error) { + if (error?.code === 'ENOENT') return null; + throw error; + } +} + +async function writeBlockedVersion(storeOptions, installedVersion) { + await writeOwnerPrivateFile( + blockPath(storeOptions), + JSON.stringify({ schema: 'spotter.runtime_report_block.v1', version: installedVersion }), + storeOptions, + ); +} + +export async function reportRuntimeErrors(options = {}) { + const config = await (options.readConfig ?? readRuntimeReportConfig)(options); + if (config.state !== 'valid' || !config.collectionEnabled || !config.reportingEnabled) { + return { status: 'disabled', reason: config.state }; + } + const storeOptions = options.storeOptions ?? {}; + const status = await (options.readStatus ?? readRuntimeErrorStoreStatus)(storeOptions); + if (status.store === 'unavailable') return { status: 'store_unavailable' }; + if (status.unacknowledged === 0) return { status: 'nothing_to_report' }; + const snapshot = await (options.readSnapshot ?? readRuntimeErrorSnapshot)({ + ...storeOptions, afterCursor: status.acknowledged_through, limit: 500, + }); + if (snapshot.collection !== 'enabled' || snapshot.records.length === 0) { + return { status: 'nothing_to_report' }; + } + const installedVersion = options.installedVersion ?? version; + if (await (options.readBlockedVersion ?? readBlockedVersion)(storeOptions) === installedVersion) { + return { status: 'invalid_report_blocked' }; + } + const credential = await (options.readCredential ?? readRuntimeReportCredential)(options); + const tag = credentialTag(credential); + if (await (options.readBlockedCredential ?? readBlockedCredential)(storeOptions) === tag) { + return { status: 'credential_rejected_blocked' }; + } + const at = new Date(options.nowMs ?? Date.now()); + const ts = String(Math.floor(at.getTime() / 1000)); + const reportId = (options.uuid ?? randomUUID)(); + const payload = { + schema_version: '1.0', report_id: reportId, product_id: 'spotter', + installed_version: installedVersion, + observed_at: at.toISOString(), runtime_errors: [], resolutions: [], + }; + let cursor = status.acknowledged_through; + let body; + for (const record of snapshot.records) { + const { item, resolution } = project(record); + payload.runtime_errors.push(item); + if (resolution) payload.resolutions.push(resolution); + const candidate = Buffer.from(JSON.stringify(payload), 'utf8'); + if (candidate.length > MAX_BODY_BYTES) { + payload.runtime_errors.pop(); + if (resolution) payload.resolutions.pop(); + if (cursor === status.acknowledged_through) return { status: 'record_too_large' }; + break; + } + body = candidate; + cursor = record.sequence; + } + const sig = signRuntimeReport(credential.secret, ts, body); + let response; + try { + response = await (options.fetchFn ?? fetch)(credential.url, { + method: 'POST', redirect: 'manual', signal: AbortSignal.timeout(10_000), body, + headers: { + 'Content-Type': 'application/json', + Authorization: `BugHub-HMAC-SHA256 key_id=${credential.key_id}, ts=${ts}, sig=${sig}`, + }, + }); + } catch { + return { status: 'unconfirmed', count: payload.runtime_errors.length }; + } + if (response.status === 401 && await responseCode(response) === 'timestamp_skew') { + return { status: 'clock_skew' }; + } + if (response.status === 401 || response.status === 403) { + await (options.writeBlockedCredential ?? writeBlockedCredential)(storeOptions, tag); + return { status: 'credential_rejected' }; + } + if (response.status === 422 && await responseCode(response) === 'observed_at_skew') { + return { status: 'clock_skew' }; + } + if (response.status === 422) { + await (options.writeBlockedVersion ?? writeBlockedVersion)(storeOptions, installedVersion); + return { status: 'invalid_report', count: payload.runtime_errors.length }; + } + if (response.status !== 200) return { status: 'unconfirmed', httpStatus: response.status, count: payload.runtime_errors.length }; + let receipt; + try { + const raw = await response.text(); + if (Buffer.byteLength(raw, 'utf8') > 16_384) throw new Error('oversized receipt'); + receipt = JSON.parse(raw); + } catch { + return { status: 'unconfirmed', count: payload.runtime_errors.length }; + } + if (!verifiedReceipt(receipt, reportId, credential.secret)) { + return { status: 'unconfirmed', count: payload.runtime_errors.length }; + } + const ack = await (options.acknowledge ?? acknowledgeRuntimeErrors)({ cursor }, storeOptions); + return { status: ack.acknowledged ? 'accepted' : 'ack_failed', count: payload.runtime_errors.length, cursor }; +} diff --git a/src/core/runtime-error-store-worker.mjs b/src/core/runtime-error-store-worker.mjs index d1f1627..2bd96a5 100644 --- a/src/core/runtime-error-store-worker.mjs +++ b/src/core/runtime-error-store-worker.mjs @@ -14,13 +14,14 @@ async function main() { process.exit(2); } const allowed = action === 'observe' - ? new Set(['configPath', 'storePath', 'productVersion', 'platform', 'arch', 'beforeOpenDelayMs', 'observationId']) - : new Set(['configPath', 'storePath', 'productVersion', 'platform', 'arch', 'observationId', 'expectedFingerprint', 'waitMs']); + ? new Set(['configPath', 'productConfigPath', 'storePath', 'productVersion', 'platform', 'arch', 'beforeOpenDelayMs', 'observationId']) + : new Set(['configPath', 'productConfigPath', 'storePath', 'productVersion', 'platform', 'arch', 'observationId', 'expectedFingerprint', 'waitMs']); const required = new Set(['configPath', 'storePath', 'productVersion', 'platform', 'arch', 'observationId']); if (!options || typeof options !== 'object' || Array.isArray(options) || Object.keys(options).some((key) => !allowed.has(key)) || [...required].some((key) => !Object.hasOwn(options, key)) || (options.configPath !== null && (typeof options.configPath !== 'string' || options.configPath.length > 4_096)) + || ('productConfigPath' in options && (typeof options.productConfigPath !== 'string' || options.productConfigPath.length < 1 || options.productConfigPath.length > 4_096)) || typeof options.storePath !== 'string' || options.storePath.length < 1 || options.storePath.length > 4_096 || typeof options.productVersion !== 'string' || options.productVersion.length < 1 || options.productVersion.length > 64 || typeof options.platform !== 'string' || !/^[a-z0-9_-]{1,32}$/.test(options.platform) diff --git a/src/core/runtime-error-store.mjs b/src/core/runtime-error-store.mjs index a2d0612..b404035 100644 --- a/src/core/runtime-error-store.mjs +++ b/src/core/runtime-error-store.mjs @@ -16,6 +16,7 @@ import { DatabaseSync } from 'node:sqlite'; import { fileURLToPath } from 'node:url'; import { version } from '../version.mjs'; import { WINDOWS_POWERSHELL_COMMAND } from '../platform/spawn.mjs'; +import { readRuntimeReportConfig } from './runtime-report-config.mjs'; export const RUNTIME_ERROR_STORE_SCHEMA = 'spotter.runtime_errors.v1'; export const RUNTIME_ERROR_STATE_SCHEMA_VERSION = '1.0'; @@ -109,6 +110,15 @@ export function runtimeErrorFingerprint(definition) { } export async function readRuntimeCollectionMode(options = {}) { + if (options.configPath == null) { + const product = await readRuntimeReportConfig(options); + if (product.state !== 'missing') { + if (product.state === 'malformed') return { mode: 'config_malformed', enabled: false }; + return product.collectionEnabled + ? { mode: 'enabled', enabled: true } + : { mode: 'disabled', enabled: false }; + } + } const configPath = options.configPath ?? defaultFactoryReporterConfigPath(options); if (!configPath) return { mode: 'config_missing', enabled: false }; let raw; @@ -223,7 +233,8 @@ export async function observeRuntimeErrorIsolatedSafe(input, options = {}) { const observationId = randomUUID().replaceAll('-', ''); const expectedFingerprint = runtimeErrorFingerprint(RUNTIME_ERROR_DEFINITIONS[kind]); const workerOptions = { - configPath: options.configPath ?? defaultFactoryReporterConfigPath(options), + configPath: options.configPath ?? null, + productConfigPath: options.productConfigPath, storePath: options.storePath ?? defaultRuntimeErrorStorePath(options), productVersion: options.productVersion ?? version, platform: options.platform ?? process.platform, diff --git a/src/core/runtime-report-config.mjs b/src/core/runtime-report-config.mjs new file mode 100644 index 0000000..4085b1a --- /dev/null +++ b/src/core/runtime-report-config.mjs @@ -0,0 +1,58 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { readOwnerPrivateFile } from '../platform/owner-private-file.mjs'; + +export const BUGHUB_RUNTIME_ERROR_URL = 'http://192.168.1.2:39310/api/products/v1/runtime-errors'; + +export function defaultRuntimeReportConfigPath({ env = process.env, platform = process.platform, homeDir = homedir() } = {}) { + if (platform === 'win32') return join(env.LOCALAPPDATA || homeDir, 'Spotter', 'runtime-errors-config.json'); + return join(env.XDG_CONFIG_HOME || join(homeDir, '.config'), 'spotter', 'runtime-errors.json'); +} + +export function defaultRuntimeReportCredentialPath({ env = process.env, platform = process.platform, homeDir = homedir() } = {}) { + if (platform === 'win32') return join(env.LOCALAPPDATA || homeDir, 'bughub', 'product-credentials', 'spotter.json'); + return join(homeDir, '.config', 'bughub', 'product-credentials', 'spotter.json'); +} + +export async function readRuntimeReportConfig(options = {}) { + const path = options.productConfigPath ?? defaultRuntimeReportConfigPath(options); + let raw; + try { raw = await readOwnerPrivateFile(path, options); } + catch (error) { + if (error?.code === 'ENOENT') return { state: 'missing', collectionEnabled: false, reportingEnabled: false }; + return { state: 'malformed', collectionEnabled: false, reportingEnabled: false }; + } + try { + const value = JSON.parse(raw); + if (!exactKeys(value, ['schema_version', 'collection', 'reporting']) + || value.schema_version !== '1.0' + || !exactKeys(value.collection, ['enabled']) || typeof value.collection.enabled !== 'boolean' + || !exactKeys(value.reporting, ['enabled']) || typeof value.reporting.enabled !== 'boolean') { + throw new Error('invalid config'); + } + return { + state: 'valid', + collectionEnabled: value.collection.enabled, + reportingEnabled: value.reporting.enabled, + }; + } catch { + return { state: 'malformed', collectionEnabled: false, reportingEnabled: false }; + } +} + +export async function readRuntimeReportCredential(options = {}) { + const path = options.credentialPath ?? defaultRuntimeReportCredentialPath(options); + const value = JSON.parse(await readOwnerPrivateFile(path, options)); + if (!exactKeys(value, ['url', 'key_id', 'secret']) + || value.url !== BUGHUB_RUNTIME_ERROR_URL + || typeof value.key_id !== 'string' || !/^[A-Za-z0-9._-]{1,128}$/.test(value.key_id) + || typeof value.secret !== 'string' || value.secret.length < 1 || value.secret.length > 512) { + throw new Error('invalid runtime report credential'); + } + return value; +} + +function exactKeys(value, keys) { + return value !== null && typeof value === 'object' && !Array.isArray(value) + && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); +} diff --git a/src/platform/owner-private-file.mjs b/src/platform/owner-private-file.mjs new file mode 100644 index 0000000..cd699b9 --- /dev/null +++ b/src/platform/owner-private-file.mjs @@ -0,0 +1,80 @@ +import { constants } from 'node:fs'; +import { chmod, lstat, open, rename, rm, writeFile } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { WINDOWS_POWERSHELL_COMMAND } from './spawn.mjs'; + +export async function readOwnerPrivateFile(path, { platform = process.platform } = {}) { + const info = await lstat(path); + if (!info.isFile() || info.isSymbolicLink()) throw new Error('private file type is unsafe'); + if (platform === 'win32') await checkWindowsOwnerAcl(path); + else if (info.uid !== process.getuid() || (info.mode & 0o777) !== 0o600) { + throw new Error('private file ownership or mode is unsafe'); + } + const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + try { + const opened = await handle.stat(); + if (!opened.isFile() || opened.dev !== info.dev || opened.ino !== info.ino) { + throw new Error('private file changed during read'); + } + if (platform !== 'win32' && (opened.uid !== process.getuid() || (opened.mode & 0o777) !== 0o600)) { + throw new Error('private file ownership or mode changed'); + } + return new TextDecoder('utf-8', { fatal: true }).decode(await handle.readFile()); + } finally { + await handle.close(); + } +} + +function checkWindowsOwnerAcl(path) { + const script = [ + '$ErrorActionPreference = "Stop"', + '$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value', + '$acl = Get-Acl -LiteralPath $env:SPOTTER_PRIVATE_FILE', + '$owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value', + '$rules = @($acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier]))', + '$unsafe = @($rules | Where-Object { $_.AccessControlType -eq "Allow" -and $_.IdentityReference.Value -ne $sid })', + 'if ($owner -ne $sid -or $unsafe.Count -gt 0) { exit 7 }', + ].join('; '); + return runPowerShellFileScript(path, script); +} + +export async function writeOwnerPrivateFile(path, content, { platform = process.platform } = {}) { + const temporary = `${path}.${randomUUID()}.tmp`; + try { + await writeFile(temporary, content, { flag: 'wx', mode: 0o600 }); + if (platform === 'win32') { + const script = [ + '$ErrorActionPreference = "Stop"', + '$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User', + '$acl = Get-Acl -LiteralPath $env:SPOTTER_PRIVATE_FILE', + '$acl.SetOwner($sid)', + '$acl.SetAccessRuleProtection($true, $false)', + '$rule = New-Object System.Security.AccessControl.FileSystemAccessRule($sid, "FullControl", [System.Security.AccessControl.InheritanceFlags]::None, [System.Security.AccessControl.PropagationFlags]::None, [System.Security.AccessControl.AccessControlType]::Allow)', + '$acl.AddAccessRule($rule)', + 'Set-Acl -LiteralPath $env:SPOTTER_PRIVATE_FILE -AclObject $acl', + ].join('; '); + await runPowerShellFileScript(temporary, script); + await checkWindowsOwnerAcl(temporary); + } else await chmod(temporary, 0o600); + await rename(temporary, path); + } catch (error) { + await rm(temporary, { force: true }).catch(() => {}); + throw error; + } +} + +function runPowerShellFileScript(path, script) { + return new Promise((resolve, reject) => { + const child = spawn(WINDOWS_POWERSHELL_COMMAND, ['-NoProfile', '-NonInteractive', '-Command', script], { + stdio: 'ignore', windowsHide: true, + env: { ...process.env, SPOTTER_PRIVATE_FILE: path }, + }); + const timer = setTimeout(() => child.kill('SIGKILL'), 15_000); + child.once('error', (error) => { clearTimeout(timer); reject(error); }); + child.once('close', (code) => { + clearTimeout(timer); + code === 0 ? resolve() : reject(new Error('private file ACL is unsafe')); + }); + }); +} diff --git a/test/runtime-error-reporter.test.mjs b/test/runtime-error-reporter.test.mjs new file mode 100644 index 0000000..a00485e --- /dev/null +++ b/test/runtime-error-reporter.test.mjs @@ -0,0 +1,177 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { chmod, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + reportRuntimeErrors, signRuntimeReport, signRuntimeReportReceipt, +} from '../src/core/runtime-error-reporter.mjs'; +import { + readRuntimeReportConfig, readRuntimeReportCredential, +} from '../src/core/runtime-report-config.mjs'; +import { + observeRuntimeError, readRuntimeCollectionMode, readRuntimeErrorStoreStatus, +} from '../src/core/runtime-error-store.mjs'; + +const secret = 'bughub-test-secret-do-not-use-0123456789abcdef'; +const reportId = '00000000-0000-4000-8000-000000000001'; +const observed = '2026-09-21T14:13:20.000Z'; +const received = '2026-09-21T14:13:21.000Z'; + +test('BugHub HMAC matches the contract request and response vectors', () => { + 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":[]}', 'utf8'); + assert.equal(body.length, 205); + assert.equal(signRuntimeReport(secret, '1790000000', body), 'e4ba0355c9d9058286a82d9622747eae264f780aa575e74859c40a6e529fa73a'); + assert.equal(signRuntimeReportReceipt(secret, reportId, received), 'cc4ebb409cdd6a2be5f69f6acf8ebcd7f18acbe14b9ac82836797572f74a64bb'); +}); + +function fixture(overrides = {}) { + const record = { + fingerprint: 'a'.repeat(64), error_code: 'SPOTTER.AUDITOR.UNAVAILABLE', component: 'auditor', + message_template: 'Spotter auditor backend was unavailable', severity: 'warn', status: 'open', + occurrence_count: 228, first_seen: observed, last_seen: observed, product_version: '1.8.0', + state_schema_version: '1.0', sequence: 43, + product: 'spotter', os: 'darwin', arch: 'arm64', resolved_at: null, reason_code: null, + }; + let acknowledged = null; + let sent = null; + const options = { + readConfig: async () => ({ state: 'valid', collectionEnabled: true, reportingEnabled: true }), + readStatus: async () => ({ store: 'available', unacknowledged: 1, acknowledged_through: 42 }), + readSnapshot: async () => ({ collection: 'enabled', records: [record] }), + readCredential: async () => ({ url: 'http://192.168.1.2:39310/api/products/v1/runtime-errors', key_id: 'key-1', secret }), + readBlockedVersion: async () => null, + writeBlockedVersion: async () => {}, + readBlockedCredential: async () => null, + writeBlockedCredential: async () => {}, + nowMs: Date.parse(observed), uuid: () => reportId, installedVersion: '1.8.0', + fetchFn: async (url, init) => { + sent = { url, init, body: JSON.parse(init.body.toString('utf8')) }; + return { status: 200, text: async () => JSON.stringify({ + accepted: true, report_id: reportId, received_at: received, + sig: signRuntimeReportReceipt(secret, reportId, received), + }) }; + }, + acknowledge: async ({ cursor }) => { acknowledged = cursor; return { acknowledged: true }; }, + ...overrides, + }; + return { options, getSent: () => sent, getAcknowledged: () => acknowledged }; +} + +test('report projects only approved fields and acknowledges after verified receipt', async () => { + const box = fixture(); + const result = await reportRuntimeErrors(box.options); + assert.deepEqual(result, { status: 'accepted', count: 1, cursor: 43 }); + assert.equal(box.getAcknowledged(), 43); + const sent = box.getSent(); + assert.equal(sent.init.redirect, 'manual'); + assert.equal(sent.body.product_id, 'spotter'); + assert.deepEqual(sent.body.resolutions, []); + assert.equal(sent.body.runtime_errors[0].occurrence_count, 228); + for (const key of ['product', 'os', 'arch', 'sequence', 'resolved_at', 'reason_code']) { + assert.equal(Object.hasOwn(sent.body.runtime_errors[0], key), false); + } + const auth = sent.init.headers.Authorization; + assert.equal(auth, `BugHub-HMAC-SHA256 key_id=key-1, ts=1790000000, sig=${signRuntimeReport(secret, '1790000000', sent.init.body)}`); +}); + +test('unverified 200, timeout, and schema rejection never acknowledge', async () => { + for (const fetchFn of [ + async () => ({ status: 200, text: async () => JSON.stringify({ accepted: true, report_id: reportId, received_at: received, sig: '0'.repeat(64) }) }), + async () => { throw new Error('timeout'); }, + async () => ({ status: 422 }), + ]) { + const box = fixture({ fetchFn }); + const result = await reportRuntimeErrors(box.options); + assert.ok(['unconfirmed', 'invalid_report'].includes(result.status)); + assert.equal(box.getAcknowledged(), null); + } +}); + +test('422 blocks another automatic send with the same installed version', async () => { + let blockedVersion = null; + let requests = 0; + const box = fixture({ + readBlockedVersion: async () => blockedVersion, + writeBlockedVersion: async (_options, value) => { blockedVersion = value; }, + fetchFn: async () => { requests += 1; return { status: 422 }; }, + }); + assert.equal((await reportRuntimeErrors(box.options)).status, 'invalid_report'); + assert.equal((await reportRuntimeErrors(box.options)).status, 'invalid_report_blocked'); + assert.equal(requests, 1); + assert.equal(box.getAcknowledged(), null); +}); + +test('401/403 credentials pause until rotation; clock skew stays retryable', async () => { + for (const status of [401, 403]) { + let blockedTag = null; + let requests = 0; + const box = fixture({ + readBlockedCredential: async () => blockedTag, + writeBlockedCredential: async (_options, tag) => { blockedTag = tag; }, + fetchFn: async () => { requests += 1; return { status, text: async () => '{}' }; }, + }); + assert.equal((await reportRuntimeErrors(box.options)).status, 'credential_rejected'); + assert.equal((await reportRuntimeErrors(box.options)).status, 'credential_rejected_blocked'); + assert.equal(requests, 1); + } + for (const [status, code] of [[401, 'timestamp_skew'], [422, 'observed_at_skew']]) { + const box = fixture({ fetchFn: async () => ({ status, text: async () => JSON.stringify({ code }) }) }); + assert.equal((await reportRuntimeErrors(box.options)).status, 'clock_skew'); + } +}); + +test('missing or disabled product config performs no credential read or network call', async () => { + for (const state of ['missing', 'valid']) { + const box = fixture({ + readConfig: async () => ({ state, collectionEnabled: true, reportingEnabled: false }), + readCredential: async () => { throw new Error('credential accessed'); }, + fetchFn: async () => { throw new Error('network accessed'); }, + }); + assert.equal((await reportRuntimeErrors(box.options)).status, 'disabled'); + } +}); + +test('product config and credential require owner-private regular files', { skip: process.platform === 'win32' }, async (t) => { + const root = await mkdtemp(join(tmpdir(), 'spotter-report-config-')); + t.after(() => rm(root, { recursive: true, force: true })); + const productConfigPath = join(root, 'runtime-errors.json'); + const credentialPath = join(root, 'spotter.json'); + await writeFile(productConfigPath, JSON.stringify({ schema_version: '1.0', collection: { enabled: true }, reporting: { enabled: true } }), { mode: 0o600 }); + await writeFile(credentialPath, JSON.stringify({ url: 'http://192.168.1.2:39310/api/products/v1/runtime-errors', key_id: 'k1', secret }), { mode: 0o600 }); + assert.equal((await readRuntimeReportConfig({ productConfigPath })).reportingEnabled, true); + assert.equal((await readRuntimeReportCredential({ credentialPath })).key_id, 'k1'); + assert.deepEqual(await readRuntimeCollectionMode({ productConfigPath }), { mode: 'enabled', enabled: true }); + await writeFile(productConfigPath, JSON.stringify({ schema_version: '1.0', collection: { enabled: false }, reporting: { enabled: true } })); + assert.deepEqual(await readRuntimeCollectionMode({ productConfigPath }), { mode: 'disabled', enabled: false }); + await chmod(credentialPath, 0o644); + await assert.rejects(readRuntimeReportCredential({ credentialPath })); + await rm(credentialPath); + await symlink(productConfigPath, credentialPath); + await assert.rejects(readRuntimeReportCredential({ credentialPath })); +}); + +test('product-owned opt-in collection flows through signed send and store acknowledgement', { skip: process.platform === 'win32' }, async (t) => { + const root = await mkdtemp(join(tmpdir(), 'spotter-report-integration-')); + t.after(() => rm(root, { recursive: true, force: true })); + const productConfigPath = join(root, 'runtime-errors.json'); + const credentialPath = join(root, 'spotter.json'); + const storePath = join(root, 'state', 'runtime-errors-v1.json'); + await writeFile(productConfigPath, JSON.stringify({ schema_version: '1.0', collection: { enabled: true }, reporting: { enabled: true } }), { mode: 0o600 }); + await writeFile(credentialPath, JSON.stringify({ url: 'http://192.168.1.2:39310/api/products/v1/runtime-errors', key_id: 'k1', secret }), { mode: 0o600 }); + const storeOptions = { productConfigPath, storePath }; + assert.equal((await observeRuntimeError('auditor_unavailable', { ...storeOptions, now: () => new Date(observed) })).collected, true); + const result = await reportRuntimeErrors({ + productConfigPath, credentialPath, storeOptions, nowMs: Date.parse(observed), + fetchFn: async (_url, init) => { + const body = JSON.parse(init.body.toString('utf8')); + assert.equal(body.runtime_errors[0].occurrence_count, 1); + return { status: 200, text: async () => JSON.stringify({ + accepted: true, report_id: body.report_id, received_at: received, + sig: signRuntimeReportReceipt(secret, body.report_id, received), + }) }; + }, + }); + assert.equal(result.status, 'accepted'); + assert.equal((await readRuntimeErrorStoreStatus(storeOptions)).unacknowledged, 0); +}); From d89e02e08714be2557b4a9d11a241096cf48dad4 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:17:33 +0000 Subject: [PATCH 2/8] Bound report batches and cover explicit resolutions --- src/core/runtime-error-reporter.mjs | 2 +- test/runtime-error-reporter.test.mjs | 22 ++++++++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/core/runtime-error-reporter.mjs b/src/core/runtime-error-reporter.mjs index b0b9c11..98e3fb2 100644 --- a/src/core/runtime-error-reporter.mjs +++ b/src/core/runtime-error-reporter.mjs @@ -138,7 +138,7 @@ export async function reportRuntimeErrors(options = {}) { }; let cursor = status.acknowledged_through; let body; - for (const record of snapshot.records) { + for (const record of snapshot.records.slice(0, 500)) { const { item, resolution } = project(record); payload.runtime_errors.push(item); if (resolution) payload.resolutions.push(resolution); diff --git a/test/runtime-error-reporter.test.mjs b/test/runtime-error-reporter.test.mjs index a00485e..2fe8854 100644 --- a/test/runtime-error-reporter.test.mjs +++ b/test/runtime-error-reporter.test.mjs @@ -10,7 +10,7 @@ import { readRuntimeReportConfig, readRuntimeReportCredential, } from '../src/core/runtime-report-config.mjs'; import { - observeRuntimeError, readRuntimeCollectionMode, readRuntimeErrorStoreStatus, + observeRuntimeError, readRuntimeCollectionMode, readRuntimeErrorStoreStatus, resolveRuntimeError, } from '../src/core/runtime-error-store.mjs'; const secret = 'bughub-test-secret-do-not-use-0123456789abcdef'; @@ -75,6 +75,20 @@ test('report projects only approved fields and acknowledges after verified recei assert.equal(auth, `BugHub-HMAC-SHA256 key_id=key-1, ts=1790000000, sig=${signRuntimeReport(secret, '1790000000', sent.init.body)}`); }); +test('a report respects the 500-record and 512-KiB limits and acknowledges only its prefix', async () => { + const seed = (await fixture().options.readSnapshot()).records[0]; + const records = Array.from({ length: 600 }, (_, index) => ({ + ...seed, fingerprint: index.toString(16).padStart(64, '0'), + sequence: index + 43, message_template: 'x'.repeat(1024), + })); + const box = fixture({ readSnapshot: async () => ({ collection: 'enabled', records }) }); + const result = await reportRuntimeErrors(box.options); + assert.equal(result.status, 'accepted'); + assert.ok(result.count < 500); + assert.ok(box.getSent().init.body.length <= 512 * 1024); + assert.equal(box.getAcknowledged(), 42 + result.count); +}); + test('unverified 200, timeout, and schema rejection never acknowledge', async () => { for (const fetchFn of [ async () => ({ status: 200, text: async () => JSON.stringify({ accepted: true, report_id: reportId, received_at: received, sig: '0'.repeat(64) }) }), @@ -160,12 +174,16 @@ test('product-owned opt-in collection flows through signed send and store acknow await writeFile(productConfigPath, JSON.stringify({ schema_version: '1.0', collection: { enabled: true }, reporting: { enabled: true } }), { mode: 0o600 }); await writeFile(credentialPath, JSON.stringify({ url: 'http://192.168.1.2:39310/api/products/v1/runtime-errors', key_id: 'k1', secret }), { mode: 0o600 }); const storeOptions = { productConfigPath, storePath }; - assert.equal((await observeRuntimeError('auditor_unavailable', { ...storeOptions, now: () => new Date(observed) })).collected, true); + const observation = await observeRuntimeError('auditor_unavailable', { ...storeOptions, now: () => new Date(observed) }); + assert.equal(observation.collected, true); + await resolveRuntimeError({ fingerprint: observation.fingerprint }, { ...storeOptions, now: () => new Date(received) }); const result = await reportRuntimeErrors({ productConfigPath, credentialPath, storeOptions, nowMs: Date.parse(observed), fetchFn: async (_url, init) => { const body = JSON.parse(init.body.toString('utf8')); assert.equal(body.runtime_errors[0].occurrence_count, 1); + assert.equal(body.runtime_errors[0].status, 'resolved'); + assert.deepEqual(body.resolutions, [{ fingerprint: observation.fingerprint, resolved_at: received, reason_code: 'operator_resolved' }]); return { status: 200, text: async () => JSON.stringify({ accepted: true, report_id: body.report_id, received_at: received, sig: signRuntimeReportReceipt(secret, body.report_id, received), From 6366ec001ee9b8b78c6d4773bc9f7e929b4c55d3 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:22:07 +0000 Subject: [PATCH 3/8] Track reporter rollout after draft implementation --- docs/open-issues.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/open-issues.md b/docs/open-issues.md index 93af9b3..120a725 100644 --- a/docs/open-issues.md +++ b/docs/open-issues.md @@ -12,7 +12,7 @@ Spotterの現在の未完事項だけを記録する。 ## P0 — BugHubへの製品報告rollout -Spotter専用のopt-in送信CLIは実装中。BugHubの受け口と各端末の合鍵は担当者側で準備中で、 +Spotter専用のopt-in送信CLIはdraft PR #5で実装済み。BugHubの受け口と各端末の合鍵は担当者側で準備中で、 Spotterのreleaseと4端末への適用にはオーナー承認が必要。既存のauditor修正PR #4も 版付きreleaseと4端末への導入まで台帳上は未解決。 From d6afe58d62ecb5d0fcb8f36b8216f94eb5f555fe Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:27:30 +0000 Subject: [PATCH 4/8] Exercise owner-private reporter files on Windows --- test/runtime-error-reporter.test.mjs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/runtime-error-reporter.test.mjs b/test/runtime-error-reporter.test.mjs index 2fe8854..ae57402 100644 --- a/test/runtime-error-reporter.test.mjs +++ b/test/runtime-error-reporter.test.mjs @@ -12,6 +12,7 @@ import { import { observeRuntimeError, readRuntimeCollectionMode, readRuntimeErrorStoreStatus, resolveRuntimeError, } from '../src/core/runtime-error-store.mjs'; +import { writeOwnerPrivateFile } from '../src/platform/owner-private-file.mjs'; const secret = 'bughub-test-secret-do-not-use-0123456789abcdef'; const reportId = '00000000-0000-4000-8000-000000000001'; @@ -193,3 +194,18 @@ test('product-owned opt-in collection flows through signed send and store acknow assert.equal(result.status, 'accepted'); assert.equal((await readRuntimeErrorStoreStatus(storeOptions)).unacknowledged, 0); }); + +test('Windows owner-private product config and credential survive ACL readback', { skip: process.platform !== 'win32' }, async (t) => { + const root = await mkdtemp(join(tmpdir(), 'spotter-report-windows-')); + t.after(() => rm(root, { recursive: true, force: true })); + const productConfigPath = join(root, 'runtime-errors.json'); + const credentialPath = join(root, 'spotter.json'); + await writeOwnerPrivateFile(productConfigPath, JSON.stringify({ + schema_version: '1.0', collection: { enabled: true }, reporting: { enabled: true }, + })); + await writeOwnerPrivateFile(credentialPath, JSON.stringify({ + url: 'http://192.168.1.2:39310/api/products/v1/runtime-errors', key_id: 'k1', secret, + })); + assert.equal((await readRuntimeReportConfig({ productConfigPath })).reportingEnabled, true); + assert.equal((await readRuntimeReportCredential({ credentialPath })).key_id, 'k1'); +}); From 82ee7dfb11b15ae3c59a0ec1bdd96277f3867eaa Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:42:37 +0000 Subject: [PATCH 5/8] Keep BugHub reporter unreleased after 1.8.1 merge --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6499ee..7263d83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,12 @@ 各節はそのversion公開時点の変更記録であり、後続versionにより置換された仕様を含む。 現行runtime契約は[`docs/00_overview.md`](https://github.com/kitepon/Spotter/blob/main/docs/00_overview.md)から辿る。 -## 1.8.1 — 2026-10-03 +## Unreleased - Spotter自身が端末内runtime-errorの累計をBugHubへ送るopt-in CLIを追加。署名済み受領だけでackし、既定では通信しない。 + +## 1.8.1 — 2026-10-03 + - Jev監査の大きなtool catalogを入力上限内のリクエストへ分け、全候補の判定とtoken usageを統合する。 上限超過のHTTP 400で監査が止まる問題を修正し、途中の失敗は部分的なpassへ変換しない。 - 製品full CIのLinux席をlinux-workstationに限定し、main-serverでfull CIを実行しない。 From 6ab4eead4267d11ea7229267138f62ddcc2afbf1 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:47:23 +0000 Subject: [PATCH 6/8] Match final BugHub credential format and Windows ownership --- src/core/runtime-report-config.mjs | 4 ++-- src/platform/owner-private-file.mjs | 6 ++++-- test/runtime-error-reporter.test.mjs | 9 +++++++-- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/core/runtime-report-config.mjs b/src/core/runtime-report-config.mjs index 4085b1a..6f25e8f 100644 --- a/src/core/runtime-report-config.mjs +++ b/src/core/runtime-report-config.mjs @@ -45,8 +45,8 @@ export async function readRuntimeReportCredential(options = {}) { const value = JSON.parse(await readOwnerPrivateFile(path, options)); if (!exactKeys(value, ['url', 'key_id', 'secret']) || value.url !== BUGHUB_RUNTIME_ERROR_URL - || typeof value.key_id !== 'string' || !/^[A-Za-z0-9._-]{1,128}$/.test(value.key_id) - || typeof value.secret !== 'string' || value.secret.length < 1 || value.secret.length > 512) { + || typeof value.key_id !== 'string' || !/^[A-Za-z0-9._:-]{1,128}$/.test(value.key_id) + || typeof value.secret !== 'string' || value.secret.length < 16 || value.secret.length > 1024) { throw new Error('invalid runtime report credential'); } return value; diff --git a/src/platform/owner-private-file.mjs b/src/platform/owner-private-file.mjs index cd699b9..a1a208d 100644 --- a/src/platform/owner-private-file.mjs +++ b/src/platform/owner-private-file.mjs @@ -30,11 +30,13 @@ function checkWindowsOwnerAcl(path) { const script = [ '$ErrorActionPreference = "Stop"', '$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value', + '$admins = "S-1-5-32-544"', + '$system = "S-1-5-18"', '$acl = Get-Acl -LiteralPath $env:SPOTTER_PRIVATE_FILE', '$owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value', '$rules = @($acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier]))', - '$unsafe = @($rules | Where-Object { $_.AccessControlType -eq "Allow" -and $_.IdentityReference.Value -ne $sid })', - 'if ($owner -ne $sid -or $unsafe.Count -gt 0) { exit 7 }', + '$unsafe = @($rules | Where-Object { $_.AccessControlType -eq "Allow" -and @($sid, $admins, $system) -notcontains $_.IdentityReference.Value })', + 'if (@($sid, $admins) -notcontains $owner -or $unsafe.Count -gt 0) { exit 7 }', ].join('; '); return runPowerShellFileScript(path, script); } diff --git a/test/runtime-error-reporter.test.mjs b/test/runtime-error-reporter.test.mjs index ae57402..25c5e46 100644 --- a/test/runtime-error-reporter.test.mjs +++ b/test/runtime-error-reporter.test.mjs @@ -156,6 +156,11 @@ test('product config and credential require owner-private regular files', { skip await writeFile(credentialPath, JSON.stringify({ url: 'http://192.168.1.2:39310/api/products/v1/runtime-errors', key_id: 'k1', secret }), { mode: 0o600 }); assert.equal((await readRuntimeReportConfig({ productConfigPath })).reportingEnabled, true); assert.equal((await readRuntimeReportCredential({ credentialPath })).key_id, 'k1'); + await writeFile(credentialPath, JSON.stringify({ + url: 'http://192.168.1.2:39310/api/products/v1/runtime-errors', + key_id: 'spotter:mac-kite', secret: 'x'.repeat(1024), + })); + assert.equal((await readRuntimeReportCredential({ credentialPath })).secret.length, 1024); assert.deepEqual(await readRuntimeCollectionMode({ productConfigPath }), { mode: 'enabled', enabled: true }); await writeFile(productConfigPath, JSON.stringify({ schema_version: '1.0', collection: { enabled: false }, reporting: { enabled: true } })); assert.deepEqual(await readRuntimeCollectionMode({ productConfigPath }), { mode: 'disabled', enabled: false }); @@ -204,8 +209,8 @@ test('Windows owner-private product config and credential survive ACL readback', schema_version: '1.0', collection: { enabled: true }, reporting: { enabled: true }, })); await writeOwnerPrivateFile(credentialPath, JSON.stringify({ - url: 'http://192.168.1.2:39310/api/products/v1/runtime-errors', key_id: 'k1', secret, + url: 'http://192.168.1.2:39310/api/products/v1/runtime-errors', key_id: 'spotter:fox', secret, })); assert.equal((await readRuntimeReportConfig({ productConfigPath })).reportingEnabled, true); - assert.equal((await readRuntimeReportCredential({ credentialPath })).key_id, 'k1'); + assert.equal((await readRuntimeReportCredential({ credentialPath })).key_id, 'spotter:fox'); }); From 9ce48f43039f7ec0e8347f337e22aa46d8f69885 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:50:52 +0000 Subject: [PATCH 7/8] Track deployed BugHub intake and pending credentials --- docs/open-issues.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/open-issues.md b/docs/open-issues.md index 120a725..e9d9596 100644 --- a/docs/open-issues.md +++ b/docs/open-issues.md @@ -12,7 +12,7 @@ Spotterの現在の未完事項だけを記録する。 ## P0 — BugHubへの製品報告rollout -Spotter専用のopt-in送信CLIはdraft PR #5で実装済み。BugHubの受け口と各端末の合鍵は担当者側で準備中で、 +Spotter専用のopt-in送信CLIはdraft PR #5で実装済み。BugHubの受け口は本番へ配備済みで、各端末の合鍵は担当者側の承認待ち。 Spotterのreleaseと4端末への適用にはオーナー承認が必要。既存のauditor修正PR #4も 版付きreleaseと4端末への導入まで台帳上は未解決。 From 911453eeedd5231c500749cddb786fc239887825 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:57:01 +0000 Subject: [PATCH 8/8] Verify isolated collection with Spotter config on Windows --- test/runtime-error-reporter.test.mjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/runtime-error-reporter.test.mjs b/test/runtime-error-reporter.test.mjs index 25c5e46..ff3a5c1 100644 --- a/test/runtime-error-reporter.test.mjs +++ b/test/runtime-error-reporter.test.mjs @@ -10,7 +10,8 @@ import { readRuntimeReportConfig, readRuntimeReportCredential, } from '../src/core/runtime-report-config.mjs'; import { - observeRuntimeError, readRuntimeCollectionMode, readRuntimeErrorStoreStatus, resolveRuntimeError, + observeRuntimeError, observeRuntimeErrorIsolatedSafe, readRuntimeCollectionMode, + readRuntimeErrorStoreStatus, resolveRuntimeError, } from '../src/core/runtime-error-store.mjs'; import { writeOwnerPrivateFile } from '../src/platform/owner-private-file.mjs'; @@ -213,4 +214,10 @@ test('Windows owner-private product config and credential survive ACL readback', })); assert.equal((await readRuntimeReportConfig({ productConfigPath })).reportingEnabled, true); assert.equal((await readRuntimeReportCredential({ credentialPath })).key_id, 'spotter:fox'); + const storePath = join(root, 'state', 'runtime-errors-v1.json'); + const observed = await observeRuntimeErrorIsolatedSafe('auditor_unavailable', { + productConfigPath, storePath, timeoutMs: 5_000, writeError: () => {}, + }); + assert.deepEqual(observed, { collected: true }); + assert.equal((await readRuntimeErrorStoreStatus({ productConfigPath, storePath })).unacknowledged, 1); });