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 +4 -0
- package/README.ja.md +18 -4
- package/README.md +31 -9
- package/bin/spotter.mjs +6 -0
- package/ops/runtime-reporting/dev.spotter.runtime-errors.plist +10 -0
- package/ops/runtime-reporting/spotter-runtime-errors.service +6 -0
- package/ops/runtime-reporting/spotter-runtime-errors.timer +10 -0
- package/package.json +2 -1
- package/src/cli/runtime-error-report-cmd.mjs +16 -0
- package/src/core/runtime-error-reporter.mjs +196 -0
- package/src/core/runtime-error-store-worker.mjs +3 -2
- package/src/core/runtime-error-store.mjs +12 -1
- package/src/core/runtime-report-config.mjs +58 -0
- package/src/platform/owner-private-file.mjs +82 -0
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
|
-
|
|
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です。
|
|
294
|
-
|
|
295
|
-
Spotter
|
|
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
|
|
25
|
-
|
|
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
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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.
|
|
317
|
-
|
|
318
|
-
|
|
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>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-spotter",
|
|
3
|
-
"version": "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 ??
|
|
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
|
+
}
|