claude-spotter 1.8.1 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,10 @@
3
3
  各節はそのversion公開時点の変更記録であり、後続versionにより置換された仕様を含む。
4
4
  現行runtime契約は[`docs/00_overview.md`](https://github.com/kitepon/Spotter/blob/main/docs/00_overview.md)から辿る。
5
5
 
6
+ ## 1.9.0 — 2026-10-03
7
+
8
+ - Spotter自身が端末内runtime-errorの累計をBugHubへ送るopt-in CLIを追加。署名済み受領だけでackし、既定では通信しない。
9
+
6
10
  ## 1.8.1 — 2026-10-03
7
11
 
8
12
  - Jev監査の大きなtool catalogを入力上限内のリクエストへ分け、全候補の判定とtoken usageを統合する。
package/README.ja.md CHANGED
@@ -22,7 +22,7 @@
22
22
  project marker、catalog discoveryとhost-local tool DB、評価store、dashboard server、
23
23
  diagnostics、installer、release packagingを所有します。
24
24
  [dotagents](https://github.com/kitepon/dotagents)が所有するのは共有agent指示と、
25
- Spotterの端末内runtime-error集計を有効化する任意のfactory-reporter設定です。
25
+ 旧来の端末内runtime-error集計に使える任意のfactory-reporter設定です。実行時エラーの送信はSpotterが所有します。
26
26
  Spotterのcatalogやhost統合はdotagentsの責務ではありません。MarkItDownは別区分の第三者CLIです。
27
27
 
28
28
  Claude には「使えるツールがあるのに、使うべきタイミングで使わない」という構造的な弱点があります。記録すべき決定を memory / caveat MCP に残さない、docs lookup MCP を呼ばずに古い知識で応答する、ブラウザ自動化 MCP で確認せず UI 状態を推測する — **「分からないと自覚できない」から、ツールを取りに行けない**。
@@ -290,9 +290,10 @@ dashboardの2つのPowerShell actionを非対話・console非表示で起動す
290
290
 
291
291
  ## 端末内runtime error集計
292
292
 
293
- factory diagnosticsとruntime error集計は既定OFFです。canonicalなdotagents factory reporter設定で
294
- JSON booleanの`collection.enabled: true`が明示された場合だけ、固定codeの失敗を端末内へ集計します。
295
- Spotterはreporting credentialもnetwork送信経路も持ちません。保存APIは固定templateとallow-list済み集計だけを受け付け、
293
+ factory diagnosticsとruntime error集計は既定OFFです。Spotter専用設定で
294
+ `collection.enabled: true`を明示した場合、固定codeの失敗を端末内へ集計します。この設定が無い端末では、
295
+ 旧dotagents factory reporter設定を収集だけの互換入力として読みます。送信はSpotter専用設定の
296
+ `reporting.enabled: true`で別に明示した時だけ行い、既定では通信しません。保存APIは固定templateとallow-list済み集計だけを受け付け、
296
297
  例外本文、stdout/stderr、stack、prompt、hook payload、finding、ファイル内容、絶対pathを保存しません。
297
298
 
298
299
  daemonとCodex hookの収集境界は、bounded timeout付きのkill可能なchild process groupで実行します。
@@ -305,6 +306,19 @@ readbackを検証します。
305
306
  受理後のlifecycle操作です。`spotter diagnostics logs`と`spotter diagnostics factory`はboundedな件数と
306
307
  statusだけを返し、store/config pathやrecord本文を出しません。
307
308
 
309
+ 指揮官の端末で送信を有効にする場合、所有者だけが読める通常ファイル(0600)を
310
+ `~/.config/spotter/runtime-errors.json`(Windowsは`%LOCALAPPDATA%\Spotter\runtime-errors-config.json`)
311
+ に置きます。内容は`{"schema_version":"1.0","collection":{"enabled":true},"reporting":{"enabled":true}}`です。
312
+ BugHub担当が別途、端末ごとの合鍵を`~/.config/bughub/product-credentials/spotter.json`
313
+ (Windowsは`%LOCALAPPDATA%\bughub\product-credentials\spotter.json`)へ置きます。
314
+ `spotter runtime-errors report`を実行し、継続時はlaunchd、systemd user timer、Windows Task Schedulerで
315
+ 同じコマンドを1時間に1回起動します。未受領だけを送信し、署名を検証した200応答だけでackします。
316
+ 配布物の`ops/runtime-reporting/`にlaunchdとsystemd user timerの雛形があります。
317
+ `REPLACE_WITH_ABSOLUTE_SPOTTER_PATH`を導入済みCLIの絶対pathへ替えてから登録します。
318
+ Windowsでは同じ利用者のTask Schedulerへ`spotter.cmd runtime-errors report`を1時間ごとに登録します。
319
+ 422が返った版は再送を止め、修正版を導入すると再試行します。無効化は`reporting.enabled`を`false`にします。
320
+ 401/403で拒まれた合鍵は入れ替わるまで再送を止めます。時計ずれはackせず、時刻修正後に再試行します。
321
+
308
322
  Primary auditor backend policy: Claude hooks の auto selection は PATH に Codex CLI があれば Codex CLI、
309
323
  なければ Haiku compatibility path。Codex native hooks の auto selection は Codex CLI です。
310
324
  `SPOTTER_AUDITOR_BACKEND` の明示 override はどちらの host でも優先し、runtime failure では別 backend へ
package/README.md CHANGED
@@ -21,8 +21,8 @@ This repository owns the complete Spotter product surface: auditor behavior,
21
21
  Claude/Codex/Cursor/Grok hook adapters, project markers, catalog discovery and host-local
22
22
  tool databases, evaluation storage, dashboard servers, diagnostics, installers,
23
23
  and release packaging. [dotagents](https://github.com/kitepon/dotagents)
24
- owns shared agent instructions and the optional factory-reporter configuration
25
- that enables Spotter's local runtime-error aggregate; it does not own Spotter's
24
+ owns shared agent instructions and an optional legacy factory-reporter configuration
25
+ for local collection; Spotter owns its product runtime-error reporting; dotagents does not own Spotter's
26
26
  catalog or host integration. MarkItDown is a separate third-party CLI.
27
27
 
28
28
  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
305
305
  error aggregate は collection が既定OFFです。npm `latest`、tag / GitHub Release、
306
306
  公開CI、registry由来installを確認済みです。
307
307
 
308
- Spotter collects fixed-code runtime failures only when the canonical dotagents factory reporter
309
- configuration contains the JSON boolean `collection.enabled: true`. Missing, malformed, and disabled
310
- configuration all fail closed. Collection is local-only: Spotter has no reporting credential or network
311
- transport code. The owner-private atomic store contains only fixed templates and allow-listed aggregates;
308
+ Spotter collects fixed-code runtime failures when its owner-private product config contains
309
+ `collection.enabled: true`. If that config is absent, the old canonical dotagents factory reporter
310
+ config remains a collection-only compatibility input. Malformed or disabled product config fails closed.
311
+ Reporting is independently opt-in with `reporting.enabled: true`; the default makes no network request.
312
+ The owner-private atomic store contains only fixed templates and allow-listed aggregates;
312
313
  raw exceptions, stdout/stderr, stacks, prompts, hook payloads, findings, file contents, and absolute paths
313
314
  are not accepted by its API.
314
315
 
315
316
  Daemon and direct Codex-hook owner boundaries perform collection in a killable child-process group with
316
- a bounded timeout. A blocked FIFO or descendant therefore cannot stall the hook or daemon. HTTP reporting
317
- metadata, when present in the shared config, is accepted only when `new URL(value).href === value` and the
318
- scheme is HTTP(S); Spotter still neither reads credentials nor sends the aggregate anywhere.
317
+ a bounded timeout. A blocked FIFO or descendant therefore cannot stall the hook or daemon. The product
318
+ report command reads only Spotter's opt-in config and the owner-private BugHub credential, never factory
319
+ reporting metadata. It sends only fixed aggregate fields to the configured owner-LAN BugHub endpoint.
319
320
 
320
321
  On POSIX, every config/store read revalidates the current uid and exact `0600` file / `0700` directory
321
322
  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
330
331
  or record payload. Programmatic consumers can import `readRuntimeErrorSnapshot`,
331
332
  `acknowledgeRuntimeErrors`, `resolveRuntimeError`, `reopenRuntimeError`, and `compactRuntimeErrors`.
332
333
  Acknowledgement is monotonic, and compaction never removes an unacknowledged record.
334
+
335
+ To enable owner-LAN reporting on one terminal, create the following `0600` owner-owned regular file
336
+ at `~/.config/spotter/runtime-errors.json` (Windows: `%LOCALAPPDATA%\Spotter\runtime-errors-config.json`):
337
+
338
+ ```json
339
+ {"schema_version":"1.0","collection":{"enabled":true},"reporting":{"enabled":true}}
340
+ ```
341
+
342
+ BugHub's operator supplies a separate `0600` credential at
343
+ `~/.config/bughub/product-credentials/spotter.json` (Windows:
344
+ `%LOCALAPPDATA%\bughub\product-credentials\spotter.json`). Run `spotter runtime-errors report`
345
+ once, then schedule the same command hourly with launchd, a systemd user timer, or Windows Task Scheduler.
346
+ The package includes launchd and systemd templates in `ops/runtime-reporting/`; replace
347
+ `REPLACE_WITH_ABSOLUTE_SPOTTER_PATH` with the installed CLI path before registering them. On Windows,
348
+ register an hourly task for the installed `spotter.cmd runtime-errors report` under the same user account.
349
+ Only unacknowledged records are sent. A signed 200 response advances the cursor; timeout, 5xx, or an
350
+ unverified response leaves it pending. A 422 blocks further sends for that installed version until a
351
+ corrected Spotter version is installed; the block marker is under the private `~/.spotter` state directory
352
+ (Windows: `%LOCALAPPDATA%\Spotter`). A rejected 401/403 credential is paused until it is rotated;
353
+ clock-skew responses leave the cursor pending for a later attempt. Disable reporting by changing
354
+ `reporting.enabled` to `false`.
333
355
  The Codex SessionStart hook refreshes `.spotter/tool-db.codex.json` in the background
334
356
  without touching the Claude DB.
335
357
  Codex CLI auditor child processes use a versioned product policy. The production selection is
package/bin/spotter.mjs CHANGED
@@ -12,6 +12,7 @@ import { runCursorHookCommand } from '../src/cli/cursor-hook-cmd.mjs';
12
12
  import { runGrokHookCommand } from '../src/cli/grok-hook-cmd.mjs';
13
13
  import { runAuditorCommand } from '../src/cli/auditor-cmd.mjs';
14
14
  import { runDiagnosticsCommand } from '../src/cli/diagnostics-cmd.mjs';
15
+ import { runRuntimeErrorReportCommand } from '../src/cli/runtime-error-report-cmd.mjs';
15
16
  import { runEvaluationCommand } from '../src/cli/evaluation-cmd.mjs';
16
17
  import { runDashboardCommand } from '../src/cli/dashboard-cmd.mjs';
17
18
  import {
@@ -53,6 +54,7 @@ Usage:
53
54
  spotter diagnostics factory emit a fixed-field read-only JSON diagnostic
54
55
  spotter diagnostics runtime-errors [snapshot|ack|resolve|reopen|compact]
55
56
  consume the local allow-listed aggregate store
57
+ spotter runtime-errors report send pending aggregates to the owner LAN BugHub (opt-in)
56
58
  spotter evaluation report [--project PATH] [--from ISO] [--to ISO] [--host HOST]
57
59
  [--tool-id ID] [--backend NAME] [--model NAME]
58
60
  [--spotter-version VERSION] [--json]
@@ -151,6 +153,10 @@ async function main() {
151
153
  }
152
154
  await runDiagnosticsCommand({ argv: rest });
153
155
  return;
156
+ case 'runtime-errors':
157
+ if (rest[0] !== 'report') throw Object.assign(new Error('usage: spotter runtime-errors report'), { exitCode: 2 });
158
+ await runRuntimeErrorReportCommand({ argv: rest.slice(1) });
159
+ return;
154
160
  case 'evaluation':
155
161
  await runEvaluationCommand({ argv: rest });
156
162
  return;
@@ -0,0 +1,10 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0"><dict>
4
+ <key>Label</key><string>dev.spotter.runtime-errors</string>
5
+ <key>ProgramArguments</key><array>
6
+ <string>REPLACE_WITH_ABSOLUTE_SPOTTER_PATH</string>
7
+ <string>runtime-errors</string><string>report</string>
8
+ </array>
9
+ <key>StartInterval</key><integer>3600</integer>
10
+ </dict></plist>
@@ -0,0 +1,6 @@
1
+ [Unit]
2
+ Description=Send pending Spotter runtime errors to owner-LAN BugHub
3
+
4
+ [Service]
5
+ Type=oneshot
6
+ ExecStart=REPLACE_WITH_ABSOLUTE_SPOTTER_PATH runtime-errors report
@@ -0,0 +1,10 @@
1
+ [Unit]
2
+ Description=Report pending Spotter runtime errors hourly
3
+
4
+ [Timer]
5
+ OnStartupSec=5m
6
+ OnUnitActiveSec=1h
7
+ Unit=spotter-runtime-errors.service
8
+
9
+ [Install]
10
+ WantedBy=timers.target
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-spotter",
3
- "version": "1.8.1",
3
+ "version": "1.9.0",
4
4
  "description": "Audit agent running alongside Claude Code that catches missed tool calls — 気づく役と実行する役の分離",
5
5
  "type": "module",
6
6
  "bin": {
@@ -50,6 +50,7 @@
50
50
  "src",
51
51
  "scripts",
52
52
  "ops/dashboard",
53
+ "ops/runtime-reporting",
53
54
  "docs/11_dashboard-operations.md",
54
55
  "README.md",
55
56
  "LICENSE",
@@ -0,0 +1,16 @@
1
+ import { reportRuntimeErrors } from '../core/runtime-error-reporter.mjs';
2
+
3
+ export async function runRuntimeErrorReportCommand({ argv = [], write = (line) => process.stdout.write(line) } = {}) {
4
+ if (argv.length !== 0) {
5
+ const error = new Error('usage: spotter runtime-errors report');
6
+ error.exitCode = 2;
7
+ throw error;
8
+ }
9
+ let result;
10
+ try { result = await reportRuntimeErrors(); }
11
+ catch {
12
+ result = { status: 'report_unavailable' };
13
+ }
14
+ write(`${JSON.stringify(result)}\n`);
15
+ if (!['accepted', 'nothing_to_report', 'disabled'].includes(result.status)) process.exitCode = 1;
16
+ }
@@ -0,0 +1,196 @@
1
+ import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
2
+ import { dirname, join } from 'node:path';
3
+ import { version } from '../version.mjs';
4
+ import { readOwnerPrivateFile, writeOwnerPrivateFile } from '../platform/owner-private-file.mjs';
5
+ import {
6
+ acknowledgeRuntimeErrors,
7
+ defaultRuntimeErrorStorePath,
8
+ readRuntimeErrorSnapshot,
9
+ readRuntimeErrorStoreStatus,
10
+ } from './runtime-error-store.mjs';
11
+ import { readRuntimeReportConfig, readRuntimeReportCredential } from './runtime-report-config.mjs';
12
+
13
+ const MAX_BODY_BYTES = 512 * 1024;
14
+ const FIELDS = [
15
+ 'fingerprint', 'error_code', 'component', 'message_template', 'severity', 'status',
16
+ 'occurrence_count', 'first_seen', 'last_seen', 'product_version', 'state_schema_version',
17
+ ];
18
+
19
+ export function signRuntimeReport(secret, ts, bodyBytes) {
20
+ const digest = createHash('sha256').update(bodyBytes).digest('hex');
21
+ return createHmac('sha256', Buffer.from(secret, 'utf8')).update(`${ts}\n${digest}`).digest('hex');
22
+ }
23
+
24
+ export function signRuntimeReportReceipt(secret, reportId, receivedAt) {
25
+ return createHmac('sha256', Buffer.from(secret, 'utf8'))
26
+ .update(`${reportId}\n${receivedAt}`).digest('hex');
27
+ }
28
+
29
+ function verifiedReceipt(value, reportId, secret) {
30
+ if (!value || value.accepted !== true || value.report_id !== reportId
31
+ || typeof value.received_at !== 'string' || typeof value.sig !== 'string'
32
+ || !/^[a-f0-9]{64}$/.test(value.sig)) return false;
33
+ const expected = signRuntimeReportReceipt(secret, reportId, value.received_at);
34
+ return timingSafeEqual(Buffer.from(value.sig, 'hex'), Buffer.from(expected, 'hex'));
35
+ }
36
+
37
+ function project(record) {
38
+ const item = Object.fromEntries(FIELDS.filter((field) => record[field] !== undefined)
39
+ .map((field) => [field, record[field]]));
40
+ const resolution = record.status === 'resolved'
41
+ ? { fingerprint: record.fingerprint, resolved_at: record.resolved_at, reason_code: record.reason_code }
42
+ : null;
43
+ return { item, resolution };
44
+ }
45
+
46
+ function blockPath(storeOptions) {
47
+ return join(dirname(storeOptions.storePath ?? defaultRuntimeErrorStorePath(storeOptions)), 'runtime-report-blocked-v1.json');
48
+ }
49
+
50
+ function credentialBlockPath(storeOptions) {
51
+ return join(dirname(storeOptions.storePath ?? defaultRuntimeErrorStorePath(storeOptions)), 'runtime-report-credential-blocked-v1.json');
52
+ }
53
+
54
+ function credentialTag(credential) {
55
+ return createHash('sha256').update(`${credential.key_id}\n${credential.secret}`, 'utf8').digest('hex');
56
+ }
57
+
58
+ async function readBlockedCredential(storeOptions) {
59
+ try {
60
+ const value = JSON.parse(await readOwnerPrivateFile(credentialBlockPath(storeOptions), storeOptions));
61
+ return value?.schema === 'spotter.runtime_report_credential_block.v1' && /^[a-f0-9]{64}$/.test(value.tag)
62
+ ? value.tag : null;
63
+ } catch (error) {
64
+ if (error?.code === 'ENOENT') return null;
65
+ throw error;
66
+ }
67
+ }
68
+
69
+ async function writeBlockedCredential(storeOptions, tag) {
70
+ await writeOwnerPrivateFile(
71
+ credentialBlockPath(storeOptions),
72
+ JSON.stringify({ schema: 'spotter.runtime_report_credential_block.v1', tag }),
73
+ storeOptions,
74
+ );
75
+ }
76
+
77
+ async function responseCode(response) {
78
+ try {
79
+ const raw = await response.text();
80
+ if (Buffer.byteLength(raw, 'utf8') > 16_384) return null;
81
+ const value = JSON.parse(raw);
82
+ return typeof value.code === 'string' ? value.code
83
+ : typeof value.error === 'string' ? value.error
84
+ : typeof value.error?.code === 'string' ? value.error.code : null;
85
+ } catch { return null; }
86
+ }
87
+
88
+ async function readBlockedVersion(storeOptions) {
89
+ try {
90
+ const value = JSON.parse(await readOwnerPrivateFile(blockPath(storeOptions), storeOptions));
91
+ return value?.schema === 'spotter.runtime_report_block.v1' && typeof value.version === 'string'
92
+ ? value.version : null;
93
+ } catch (error) {
94
+ if (error?.code === 'ENOENT') return null;
95
+ throw error;
96
+ }
97
+ }
98
+
99
+ async function writeBlockedVersion(storeOptions, installedVersion) {
100
+ await writeOwnerPrivateFile(
101
+ blockPath(storeOptions),
102
+ JSON.stringify({ schema: 'spotter.runtime_report_block.v1', version: installedVersion }),
103
+ storeOptions,
104
+ );
105
+ }
106
+
107
+ export async function reportRuntimeErrors(options = {}) {
108
+ const config = await (options.readConfig ?? readRuntimeReportConfig)(options);
109
+ if (config.state !== 'valid' || !config.collectionEnabled || !config.reportingEnabled) {
110
+ return { status: 'disabled', reason: config.state };
111
+ }
112
+ const storeOptions = options.storeOptions ?? {};
113
+ const status = await (options.readStatus ?? readRuntimeErrorStoreStatus)(storeOptions);
114
+ if (status.store === 'unavailable') return { status: 'store_unavailable' };
115
+ if (status.unacknowledged === 0) return { status: 'nothing_to_report' };
116
+ const snapshot = await (options.readSnapshot ?? readRuntimeErrorSnapshot)({
117
+ ...storeOptions, afterCursor: status.acknowledged_through, limit: 500,
118
+ });
119
+ if (snapshot.collection !== 'enabled' || snapshot.records.length === 0) {
120
+ return { status: 'nothing_to_report' };
121
+ }
122
+ const installedVersion = options.installedVersion ?? version;
123
+ if (await (options.readBlockedVersion ?? readBlockedVersion)(storeOptions) === installedVersion) {
124
+ return { status: 'invalid_report_blocked' };
125
+ }
126
+ const credential = await (options.readCredential ?? readRuntimeReportCredential)(options);
127
+ const tag = credentialTag(credential);
128
+ if (await (options.readBlockedCredential ?? readBlockedCredential)(storeOptions) === tag) {
129
+ return { status: 'credential_rejected_blocked' };
130
+ }
131
+ const at = new Date(options.nowMs ?? Date.now());
132
+ const ts = String(Math.floor(at.getTime() / 1000));
133
+ const reportId = (options.uuid ?? randomUUID)();
134
+ const payload = {
135
+ schema_version: '1.0', report_id: reportId, product_id: 'spotter',
136
+ installed_version: installedVersion,
137
+ observed_at: at.toISOString(), runtime_errors: [], resolutions: [],
138
+ };
139
+ let cursor = status.acknowledged_through;
140
+ let body;
141
+ for (const record of snapshot.records.slice(0, 500)) {
142
+ const { item, resolution } = project(record);
143
+ payload.runtime_errors.push(item);
144
+ if (resolution) payload.resolutions.push(resolution);
145
+ const candidate = Buffer.from(JSON.stringify(payload), 'utf8');
146
+ if (candidate.length > MAX_BODY_BYTES) {
147
+ payload.runtime_errors.pop();
148
+ if (resolution) payload.resolutions.pop();
149
+ if (cursor === status.acknowledged_through) return { status: 'record_too_large' };
150
+ break;
151
+ }
152
+ body = candidate;
153
+ cursor = record.sequence;
154
+ }
155
+ const sig = signRuntimeReport(credential.secret, ts, body);
156
+ let response;
157
+ try {
158
+ response = await (options.fetchFn ?? fetch)(credential.url, {
159
+ method: 'POST', redirect: 'manual', signal: AbortSignal.timeout(10_000), body,
160
+ headers: {
161
+ 'Content-Type': 'application/json',
162
+ Authorization: `BugHub-HMAC-SHA256 key_id=${credential.key_id}, ts=${ts}, sig=${sig}`,
163
+ },
164
+ });
165
+ } catch {
166
+ return { status: 'unconfirmed', count: payload.runtime_errors.length };
167
+ }
168
+ if (response.status === 401 && await responseCode(response) === 'timestamp_skew') {
169
+ return { status: 'clock_skew' };
170
+ }
171
+ if (response.status === 401 || response.status === 403) {
172
+ await (options.writeBlockedCredential ?? writeBlockedCredential)(storeOptions, tag);
173
+ return { status: 'credential_rejected' };
174
+ }
175
+ if (response.status === 422 && await responseCode(response) === 'observed_at_skew') {
176
+ return { status: 'clock_skew' };
177
+ }
178
+ if (response.status === 422) {
179
+ await (options.writeBlockedVersion ?? writeBlockedVersion)(storeOptions, installedVersion);
180
+ return { status: 'invalid_report', count: payload.runtime_errors.length };
181
+ }
182
+ if (response.status !== 200) return { status: 'unconfirmed', httpStatus: response.status, count: payload.runtime_errors.length };
183
+ let receipt;
184
+ try {
185
+ const raw = await response.text();
186
+ if (Buffer.byteLength(raw, 'utf8') > 16_384) throw new Error('oversized receipt');
187
+ receipt = JSON.parse(raw);
188
+ } catch {
189
+ return { status: 'unconfirmed', count: payload.runtime_errors.length };
190
+ }
191
+ if (!verifiedReceipt(receipt, reportId, credential.secret)) {
192
+ return { status: 'unconfirmed', count: payload.runtime_errors.length };
193
+ }
194
+ const ack = await (options.acknowledge ?? acknowledgeRuntimeErrors)({ cursor }, storeOptions);
195
+ return { status: ack.acknowledged ? 'accepted' : 'ack_failed', count: payload.runtime_errors.length, cursor };
196
+ }
@@ -14,13 +14,14 @@ async function main() {
14
14
  process.exit(2);
15
15
  }
16
16
  const allowed = action === 'observe'
17
- ? new Set(['configPath', 'storePath', 'productVersion', 'platform', 'arch', 'beforeOpenDelayMs', 'observationId'])
18
- : new Set(['configPath', 'storePath', 'productVersion', 'platform', 'arch', 'observationId', 'expectedFingerprint', 'waitMs']);
17
+ ? new Set(['configPath', 'productConfigPath', 'storePath', 'productVersion', 'platform', 'arch', 'beforeOpenDelayMs', 'observationId'])
18
+ : new Set(['configPath', 'productConfigPath', 'storePath', 'productVersion', 'platform', 'arch', 'observationId', 'expectedFingerprint', 'waitMs']);
19
19
  const required = new Set(['configPath', 'storePath', 'productVersion', 'platform', 'arch', 'observationId']);
20
20
  if (!options || typeof options !== 'object' || Array.isArray(options)
21
21
  || Object.keys(options).some((key) => !allowed.has(key))
22
22
  || [...required].some((key) => !Object.hasOwn(options, key))
23
23
  || (options.configPath !== null && (typeof options.configPath !== 'string' || options.configPath.length > 4_096))
24
+ || ('productConfigPath' in options && (typeof options.productConfigPath !== 'string' || options.productConfigPath.length < 1 || options.productConfigPath.length > 4_096))
24
25
  || typeof options.storePath !== 'string' || options.storePath.length < 1 || options.storePath.length > 4_096
25
26
  || typeof options.productVersion !== 'string' || options.productVersion.length < 1 || options.productVersion.length > 64
26
27
  || typeof options.platform !== 'string' || !/^[a-z0-9_-]{1,32}$/.test(options.platform)
@@ -16,6 +16,7 @@ import { DatabaseSync } from 'node:sqlite';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { version } from '../version.mjs';
18
18
  import { WINDOWS_POWERSHELL_COMMAND } from '../platform/spawn.mjs';
19
+ import { readRuntimeReportConfig } from './runtime-report-config.mjs';
19
20
 
20
21
  export const RUNTIME_ERROR_STORE_SCHEMA = 'spotter.runtime_errors.v1';
21
22
  export const RUNTIME_ERROR_STATE_SCHEMA_VERSION = '1.0';
@@ -109,6 +110,15 @@ export function runtimeErrorFingerprint(definition) {
109
110
  }
110
111
 
111
112
  export async function readRuntimeCollectionMode(options = {}) {
113
+ if (options.configPath == null) {
114
+ const product = await readRuntimeReportConfig(options);
115
+ if (product.state !== 'missing') {
116
+ if (product.state === 'malformed') return { mode: 'config_malformed', enabled: false };
117
+ return product.collectionEnabled
118
+ ? { mode: 'enabled', enabled: true }
119
+ : { mode: 'disabled', enabled: false };
120
+ }
121
+ }
112
122
  const configPath = options.configPath ?? defaultFactoryReporterConfigPath(options);
113
123
  if (!configPath) return { mode: 'config_missing', enabled: false };
114
124
  let raw;
@@ -223,7 +233,8 @@ export async function observeRuntimeErrorIsolatedSafe(input, options = {}) {
223
233
  const observationId = randomUUID().replaceAll('-', '');
224
234
  const expectedFingerprint = runtimeErrorFingerprint(RUNTIME_ERROR_DEFINITIONS[kind]);
225
235
  const workerOptions = {
226
- configPath: options.configPath ?? defaultFactoryReporterConfigPath(options),
236
+ configPath: options.configPath ?? null,
237
+ productConfigPath: options.productConfigPath,
227
238
  storePath: options.storePath ?? defaultRuntimeErrorStorePath(options),
228
239
  productVersion: options.productVersion ?? version,
229
240
  platform: options.platform ?? process.platform,
@@ -0,0 +1,58 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { readOwnerPrivateFile } from '../platform/owner-private-file.mjs';
4
+
5
+ export const BUGHUB_RUNTIME_ERROR_URL = 'http://192.168.1.2:39310/api/products/v1/runtime-errors';
6
+
7
+ export function defaultRuntimeReportConfigPath({ env = process.env, platform = process.platform, homeDir = homedir() } = {}) {
8
+ if (platform === 'win32') return join(env.LOCALAPPDATA || homeDir, 'Spotter', 'runtime-errors-config.json');
9
+ return join(env.XDG_CONFIG_HOME || join(homeDir, '.config'), 'spotter', 'runtime-errors.json');
10
+ }
11
+
12
+ export function defaultRuntimeReportCredentialPath({ env = process.env, platform = process.platform, homeDir = homedir() } = {}) {
13
+ if (platform === 'win32') return join(env.LOCALAPPDATA || homeDir, 'bughub', 'product-credentials', 'spotter.json');
14
+ return join(homeDir, '.config', 'bughub', 'product-credentials', 'spotter.json');
15
+ }
16
+
17
+ export async function readRuntimeReportConfig(options = {}) {
18
+ const path = options.productConfigPath ?? defaultRuntimeReportConfigPath(options);
19
+ let raw;
20
+ try { raw = await readOwnerPrivateFile(path, options); }
21
+ catch (error) {
22
+ if (error?.code === 'ENOENT') return { state: 'missing', collectionEnabled: false, reportingEnabled: false };
23
+ return { state: 'malformed', collectionEnabled: false, reportingEnabled: false };
24
+ }
25
+ try {
26
+ const value = JSON.parse(raw);
27
+ if (!exactKeys(value, ['schema_version', 'collection', 'reporting'])
28
+ || value.schema_version !== '1.0'
29
+ || !exactKeys(value.collection, ['enabled']) || typeof value.collection.enabled !== 'boolean'
30
+ || !exactKeys(value.reporting, ['enabled']) || typeof value.reporting.enabled !== 'boolean') {
31
+ throw new Error('invalid config');
32
+ }
33
+ return {
34
+ state: 'valid',
35
+ collectionEnabled: value.collection.enabled,
36
+ reportingEnabled: value.reporting.enabled,
37
+ };
38
+ } catch {
39
+ return { state: 'malformed', collectionEnabled: false, reportingEnabled: false };
40
+ }
41
+ }
42
+
43
+ export async function readRuntimeReportCredential(options = {}) {
44
+ const path = options.credentialPath ?? defaultRuntimeReportCredentialPath(options);
45
+ const value = JSON.parse(await readOwnerPrivateFile(path, options));
46
+ if (!exactKeys(value, ['url', 'key_id', 'secret'])
47
+ || value.url !== BUGHUB_RUNTIME_ERROR_URL
48
+ || typeof value.key_id !== 'string' || !/^[A-Za-z0-9._:-]{1,128}$/.test(value.key_id)
49
+ || typeof value.secret !== 'string' || value.secret.length < 16 || value.secret.length > 1024) {
50
+ throw new Error('invalid runtime report credential');
51
+ }
52
+ return value;
53
+ }
54
+
55
+ function exactKeys(value, keys) {
56
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
57
+ && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
58
+ }
@@ -0,0 +1,82 @@
1
+ import { constants } from 'node:fs';
2
+ import { chmod, lstat, open, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { spawn } from 'node:child_process';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { WINDOWS_POWERSHELL_COMMAND } from './spawn.mjs';
6
+
7
+ export async function readOwnerPrivateFile(path, { platform = process.platform } = {}) {
8
+ const info = await lstat(path);
9
+ if (!info.isFile() || info.isSymbolicLink()) throw new Error('private file type is unsafe');
10
+ if (platform === 'win32') await checkWindowsOwnerAcl(path);
11
+ else if (info.uid !== process.getuid() || (info.mode & 0o777) !== 0o600) {
12
+ throw new Error('private file ownership or mode is unsafe');
13
+ }
14
+ const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
15
+ try {
16
+ const opened = await handle.stat();
17
+ if (!opened.isFile() || opened.dev !== info.dev || opened.ino !== info.ino) {
18
+ throw new Error('private file changed during read');
19
+ }
20
+ if (platform !== 'win32' && (opened.uid !== process.getuid() || (opened.mode & 0o777) !== 0o600)) {
21
+ throw new Error('private file ownership or mode changed');
22
+ }
23
+ return new TextDecoder('utf-8', { fatal: true }).decode(await handle.readFile());
24
+ } finally {
25
+ await handle.close();
26
+ }
27
+ }
28
+
29
+ function checkWindowsOwnerAcl(path) {
30
+ const script = [
31
+ '$ErrorActionPreference = "Stop"',
32
+ '$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value',
33
+ '$admins = "S-1-5-32-544"',
34
+ '$system = "S-1-5-18"',
35
+ '$acl = Get-Acl -LiteralPath $env:SPOTTER_PRIVATE_FILE',
36
+ '$owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value',
37
+ '$rules = @($acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier]))',
38
+ '$unsafe = @($rules | Where-Object { $_.AccessControlType -eq "Allow" -and @($sid, $admins, $system) -notcontains $_.IdentityReference.Value })',
39
+ 'if (@($sid, $admins) -notcontains $owner -or $unsafe.Count -gt 0) { exit 7 }',
40
+ ].join('; ');
41
+ return runPowerShellFileScript(path, script);
42
+ }
43
+
44
+ export async function writeOwnerPrivateFile(path, content, { platform = process.platform } = {}) {
45
+ const temporary = `${path}.${randomUUID()}.tmp`;
46
+ try {
47
+ await writeFile(temporary, content, { flag: 'wx', mode: 0o600 });
48
+ if (platform === 'win32') {
49
+ const script = [
50
+ '$ErrorActionPreference = "Stop"',
51
+ '$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User',
52
+ '$acl = Get-Acl -LiteralPath $env:SPOTTER_PRIVATE_FILE',
53
+ '$acl.SetOwner($sid)',
54
+ '$acl.SetAccessRuleProtection($true, $false)',
55
+ '$rule = New-Object System.Security.AccessControl.FileSystemAccessRule($sid, "FullControl", [System.Security.AccessControl.InheritanceFlags]::None, [System.Security.AccessControl.PropagationFlags]::None, [System.Security.AccessControl.AccessControlType]::Allow)',
56
+ '$acl.AddAccessRule($rule)',
57
+ 'Set-Acl -LiteralPath $env:SPOTTER_PRIVATE_FILE -AclObject $acl',
58
+ ].join('; ');
59
+ await runPowerShellFileScript(temporary, script);
60
+ await checkWindowsOwnerAcl(temporary);
61
+ } else await chmod(temporary, 0o600);
62
+ await rename(temporary, path);
63
+ } catch (error) {
64
+ await rm(temporary, { force: true }).catch(() => {});
65
+ throw error;
66
+ }
67
+ }
68
+
69
+ function runPowerShellFileScript(path, script) {
70
+ return new Promise((resolve, reject) => {
71
+ const child = spawn(WINDOWS_POWERSHELL_COMMAND, ['-NoProfile', '-NonInteractive', '-Command', script], {
72
+ stdio: 'ignore', windowsHide: true,
73
+ env: { ...process.env, SPOTTER_PRIVATE_FILE: path },
74
+ });
75
+ const timer = setTimeout(() => child.kill('SIGKILL'), 15_000);
76
+ child.once('error', (error) => { clearTimeout(timer); reject(error); });
77
+ child.once('close', (code) => {
78
+ clearTimeout(timer);
79
+ code === 0 ? resolve() : reject(new Error('private file ACL is unsafe'));
80
+ });
81
+ });
82
+ }