@quolu/lattice 0.73.0 → 0.75.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/bin/lattice.mjs CHANGED
@@ -177,6 +177,29 @@ if (help !== null) {
177
177
  }
178
178
  }
179
179
 
180
+ await pendingObservation;
181
+ await scheduleRuntimeErrorReport();
182
+
183
+ /**
184
+ * 未受領のruntime error記録を、切り離した子processで送らせる(ADR 0193)。このCLIの応答は待たせない。
185
+ * 送信を有効にしていない端末では、設定fileの有無を1回見るだけで終わる。hookは打鍵ごとに走るので見ない。
186
+ */
187
+ async function scheduleRuntimeErrorReport() {
188
+ if (help !== null || args[0] === '--version' || args[0] === 'hooks'
189
+ || (args[0] === 'runtime-errors' && args[1] === 'report')) return;
190
+ try {
191
+ const { runtimeErrorReportingEnabled } = await import('../src/runtime-errors.mjs');
192
+ if (!runtimeErrorReportingEnabled()) return;
193
+ const { runtimeErrorAutoReportDue } = await import('../src/runtime-error-reporting.mjs');
194
+ if (!runtimeErrorAutoReportDue()) return;
195
+ const { spawn } = await import('node:child_process');
196
+ const { tmpdir } = await import('node:os');
197
+ const { fileURLToPath } = await import('node:url');
198
+ spawn(process.execPath, [fileURLToPath(import.meta.url), 'runtime-errors', 'report', '--auto', '--json'],
199
+ { cwd: tmpdir(), detached: true, stdio: 'ignore', windowsHide: true }).unref();
200
+ } catch { /* 送信の予約に失敗しても、打たれたcommandの結果は変えない。 */ }
201
+ }
202
+
180
203
  // typed契約の外へ漏れた例外を、opt-inのruntime error記録へ残す。どの面で・どの種類の例外で
181
204
  // 落ちたかを固定語彙の分類(safe_context)で添える——error_codeだけでは原因を追えなかった。
182
205
  function observeInternalFailure(error, commandKind) {
@@ -199,7 +222,7 @@ function workingDirectory() {
199
222
  async function runRuntimeErrorsCli(rest) {
200
223
  const runtimeErrors = await import('../src/runtime-errors.mjs');
201
224
  const usage = () => {
202
- process.stderr.write(`${JSON.stringify({ schema: 'lattice.cli_error.v2', code: 'USAGE', message: 'usage: lattice runtime-errors <snapshot [--after-cursor N] [--limit N]|ack <cursor>|diagnostics|resolve <fingerprint>|reopen <fingerprint>|compact> --json' })}\n`);
225
+ process.stderr.write(`${JSON.stringify({ schema: 'lattice.cli_error.v2', code: 'USAGE', message: 'usage: lattice runtime-errors <snapshot [--after-cursor N] [--limit N]|ack <cursor>|diagnostics|resolve <fingerprint>|reopen <fingerprint>|compact|report|reporting <status|enable|disable>> --json' })}\n`);
203
226
  return 2;
204
227
  };
205
228
  const options = { version: packageJson.version };
@@ -228,6 +251,16 @@ async function runRuntimeErrorsCli(rest) {
228
251
  result = runtimeErrors.setRuntimeErrorStatus(words[1], words[0] === 'resolve' ? 'resolved' : 'open', options);
229
252
  } else if (words[0] === 'compact' && words.length === 1) {
230
253
  result = runtimeErrors.compactRuntimeErrors(options);
254
+ } else if (words[0] === 'reporting' && words.length === 2 && ['status', 'enable', 'disable'].includes(words[1])) {
255
+ const reporting = await import('../src/runtime-error-reporting.mjs');
256
+ result = words[1] === 'status' ? reporting.runtimeErrorReportingStatus(options)
257
+ : reporting.setRuntimeErrorReporting(words[1] === 'enable', options);
258
+ } else if (words[0] === 'report' && (words.length === 1 || (words.length === 2 && words[1] === '--auto'))) {
259
+ // 送れなかった時も結果は1行で返す。受領まで済んだ時と、送るものが無い時だけexit 0。
260
+ const reporting = await import('../src/runtime-error-reporting.mjs');
261
+ result = await reporting.reportRuntimeErrors({ ...options, auto: words.length === 2 });
262
+ process.stdout.write(`${JSON.stringify(result)}\n`);
263
+ return ['delivered', 'nothing_pending'].includes(result.outcome) ? 0 : 1;
231
264
  } else {
232
265
  return usage();
233
266
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.73.0",
3
+ "version": "0.75.0",
4
4
  "description": "Schedulability compiler for multi-agent development: observe real code boundaries, refactor the conflicting seam, recompile the plan for parallel execution",
5
5
  "author": {
6
6
  "name": "Quo / クオ at kitepon.dev",
package/src/cli-help.mjs CHANGED
@@ -196,6 +196,8 @@ Commands:
196
196
  resolve <fingerprint>
197
197
  reopen <fingerprint>
198
198
  compact
199
+ report 未受領の記録をBugHubの受け口へ送る(送信を有効にした端末だけ)
200
+ reporting <status|enable|disable>
199
201
  `,
200
202
  bridge: `Usage: lattice bridge <command> [options] --json
201
203
 
@@ -312,6 +314,8 @@ const SUBCOMMAND_USAGE = Object.freeze({
312
314
  'runtime-errors resolve': 'runtime-errors resolve <fingerprint> --json',
313
315
  'runtime-errors reopen': 'runtime-errors reopen <fingerprint> --json',
314
316
  'runtime-errors compact': 'runtime-errors compact --json',
317
+ 'runtime-errors report': 'runtime-errors report --json',
318
+ 'runtime-errors reporting': 'runtime-errors reporting <status|enable|disable> --json',
315
319
  'bridge setup': 'bridge setup --listen <IP> [--port <49152..65535|auto>] [--dashboard|--upstream <URL>] [--hub <URL>|none] [--allow-host <host>...] --json',
316
320
  'bridge reconfigure': 'bridge reconfigure [--listen <IP>] [--port <49152..65535|auto>] [--dashboard|--upstream <URL>] [--hub <URL>|none] [--allow-host <host>...] --json',
317
321
  'bridge status': 'bridge status --json',
@@ -0,0 +1,365 @@
1
+ import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
2
+ import {
3
+ existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync,
4
+ } from 'node:fs';
5
+ import http from 'node:http';
6
+ import https from 'node:https';
7
+ import { homedir, platform as hostPlatform } from 'node:os';
8
+ import { dirname, join } from 'node:path';
9
+
10
+ import {
11
+ REPORTING_CONFIG_SCHEMA,
12
+ acknowledgeRuntimeErrors,
13
+ ensureSafeDir,
14
+ ensureSafeFile,
15
+ replaceStoreFile,
16
+ runtimeErrorCollectionSupported,
17
+ runtimeErrorReportingConfigPath,
18
+ runtimeErrorReportingEnabled,
19
+ runtimeErrorsDiagnostics,
20
+ runtimeErrorsSnapshot,
21
+ runtimeErrorsStatePath,
22
+ windowsLocalAppData,
23
+ } from './runtime-errors.mjs';
24
+
25
+ /**
26
+ * runtime error記録を、Lattice自身がBugHubの製品報告の受け口へ送る(ADR 0193)。
27
+ *
28
+ * - 既定では通信しない。端末で`runtime-errors reporting enable`を打ち、BugHubの持ち主が合鍵のfileを
29
+ * 置いた端末だけが送る。どちらかが欠ければ、networkへ触れない。
30
+ * - 秘密は通信に載せない。本文のSHA-256と時刻へのHMAC-SHA256署名だけを`Authorization`に付ける。
31
+ * - 受領済みにするのは、200・`accepted: true`・`report_id`一致・応答の署名一致がそろった時だけ。
32
+ * そろわなければ「届いたか不明」として記録を未受領のまま残し、後から今の累計を送り直す。
33
+ * - 送るのは`runtime-errors snapshot`が出す項目だけ。message本文・path・引数・stackは記録に無い。
34
+ */
35
+
36
+ const REPORT_SCHEMA_VERSION = '1.0';
37
+ const RESULT_SCHEMA = 'lattice.runtime_error_report_result.v1';
38
+ const STATUS_SCHEMA = 'lattice.runtime_error_reporting_status.v1';
39
+ const DELIVERY_SCHEMA = 'lattice.runtime_error_delivery.v1';
40
+ const PRODUCT_ID = 'lattice';
41
+ const REQUEST_TIMEOUT_MS = 10_000;
42
+ const RESPONSE_MAX_BYTES = 64 * 1024;
43
+ const REPORT_MAX_BYTES = 512 * 1024;
44
+ const REPORT_MAX_RECORDS = 256;
45
+ // BugHubは端末×製品ごとに1分に1回まで受ける。届かなかった同じ中身の送り直しは1時間に1回まで。
46
+ const MIN_ATTEMPT_INTERVAL_MS = 60_000;
47
+ const RETRY_INTERVAL_MS = 60 * 60_000;
48
+ const KEY_ID = /^[A-Za-z0-9._:-]{1,128}$/;
49
+ const REJECTION_CODE = /^[a-z][a-z0-9_]{0,63}$/;
50
+ const OUTCOMES = Object.freeze(['delivered', 'nothing_pending', 'disabled', 'unsupported', 'credential_missing',
51
+ 'credential_unsafe', 'throttled', 'rejected', 'unconfirmed', 'store_unavailable']);
52
+
53
+ const plain = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
54
+ const exact = (value, keys) => Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
55
+
56
+ /** 合鍵の置き場(BugHubの契約)。Windowsは`%LOCALAPPDATA%\bughub\product-credentials\`。 */
57
+ export function productCredentialPath(env = process.env, platform = hostPlatform()) {
58
+ const base = platform === 'win32' ? join(windowsLocalAppData(env), 'bughub') : join(env.HOME || homedir(), '.config', 'bughub');
59
+ return join(base, 'product-credentials', `${PRODUCT_ID}.json`);
60
+ }
61
+
62
+ /**
63
+ * Windowsの合鍵が本人・SYSTEM・Administratorsだけのものか。DACLの読み取りは出力fileを置く場所が要るので、
64
+ * 絞ったstoreのフォルダを使う(無ければ作る)。合鍵のフォルダへは何も書かない。
65
+ */
66
+ function windowsCredentialUnsafeReason(path, options) {
67
+ const storeDir = dirname(options.storePath ?? runtimeErrorsStatePath(options.env ?? process.env));
68
+ try {
69
+ ensureSafeDir(storeDir);
70
+ } catch {
71
+ return 'acl_unverifiable';
72
+ }
73
+ try {
74
+ ensureSafeFile(path, storeDir);
75
+ return null;
76
+ } catch {
77
+ return 'acl_not_owner_only';
78
+ }
79
+ }
80
+
81
+ /**
82
+ * 合鍵のfileを読む。BugHubの持ち主が置く形(symlinkでなく、POSIXは本人所有・0600、Windowsは
83
+ * 本人・SYSTEM・Administratorsだけに権限)以外は使わない。秘密は戻り値の中だけに留め、結果や記録へ写さない。
84
+ */
85
+ export function readProductCredential(options = {}) {
86
+ const path = options.credentialPath ?? productCredentialPath(options.env ?? process.env);
87
+ let stats;
88
+ try {
89
+ stats = lstatSync(path);
90
+ } catch (error) {
91
+ return error?.code === 'ENOENT' ? { status: 'missing' } : { status: 'unsafe', reason: 'unreadable' };
92
+ }
93
+ if (!stats.isFile() || stats.isSymbolicLink()) return { status: 'unsafe', reason: 'not_regular_file' };
94
+ if (hostPlatform() === 'win32') {
95
+ const reason = windowsCredentialUnsafeReason(path, options);
96
+ if (reason !== null) return { status: 'unsafe', reason };
97
+ } else {
98
+ if ((stats.mode & 0o777) !== 0o600) return { status: 'unsafe', reason: 'mode_not_0600' };
99
+ if (typeof process.getuid === 'function' && stats.uid !== process.getuid()) return { status: 'unsafe', reason: 'owner_mismatch' };
100
+ }
101
+ let value;
102
+ try {
103
+ value = JSON.parse(readFileSync(path, 'utf8'));
104
+ } catch {
105
+ return { status: 'unsafe', reason: 'not_json' };
106
+ }
107
+ if (!plain(value) || !exact(value, ['url', 'key_id', 'secret'])
108
+ || typeof value.url !== 'string' || typeof value.key_id !== 'string' || typeof value.secret !== 'string'
109
+ || !KEY_ID.test(value.key_id) || value.secret.length < 16 || value.secret.length > 1024) {
110
+ return { status: 'unsafe', reason: 'invalid_shape' };
111
+ }
112
+ let url;
113
+ try {
114
+ url = new URL(value.url);
115
+ } catch {
116
+ return { status: 'unsafe', reason: 'invalid_url' };
117
+ }
118
+ if (!['http:', 'https:'].includes(url.protocol) || url.username !== '' || url.password !== '') {
119
+ return { status: 'unsafe', reason: 'invalid_url' };
120
+ }
121
+ return { status: 'ok', credential: { url, keyId: value.key_id, secret: value.secret } };
122
+ }
123
+
124
+ const sha256Hex = (bytes) => createHash('sha256').update(bytes).digest('hex');
125
+ const hmacHex = (secret, text) => createHmac('sha256', Buffer.from(secret, 'utf8')).update(text, 'utf8').digest('hex');
126
+
127
+ /** `sig = HMAC-SHA256(secret, ts + "\n" + SHA-256(送るバイト列の16進))`。秘密は文字列のままUTF-8で鍵にする。 */
128
+ export function signReport(secret, ts, bodyBytes) {
129
+ return hmacHex(secret, `${ts}\n${sha256Hex(bodyBytes)}`);
130
+ }
131
+
132
+ /** 応答の署名: `HMAC-SHA256(secret, report_id + "\n" + received_at)`。別の機器が返した200を受領済みにしない。 */
133
+ export function receiptSignatureMatches(secret, reportId, receivedAt, sig) {
134
+ if (typeof receivedAt !== 'string' || typeof sig !== 'string' || !/^[0-9a-f]{64}$/.test(sig)) return false;
135
+ const expected = Buffer.from(hmacHex(secret, `${reportId}\n${receivedAt}`), 'hex');
136
+ return timingSafeEqual(expected, Buffer.from(sig, 'hex'));
137
+ }
138
+
139
+ /** snapshotの未受領分から、受け口へ送る本文を組む。項目はsnapshotが出すものをそのまま載せる。 */
140
+ export function buildRuntimeErrorReport({ snapshot, version, reportId, observedAt }) {
141
+ return {
142
+ schema_version: REPORT_SCHEMA_VERSION,
143
+ report_id: reportId,
144
+ product_id: PRODUCT_ID,
145
+ installed_version: version,
146
+ observed_at: observedAt,
147
+ runtime_errors: snapshot.runtime_errors,
148
+ resolutions: snapshot.resolutions,
149
+ };
150
+ }
151
+
152
+ function deliveryStatePath(options) {
153
+ const env = options.env ?? process.env;
154
+ return join(dirname(options.storePath ?? runtimeErrorsStatePath(env)), 'runtime-errors-delivery.json');
155
+ }
156
+
157
+ const emptyDelivery = () => ({ schema: DELIVERY_SCHEMA, last_attempt_at: null, last_outcome: null, attempted_through: 0 });
158
+
159
+ function readDelivery(options) {
160
+ const path = deliveryStatePath(options);
161
+ if (!existsSync(path)) return emptyDelivery();
162
+ try {
163
+ ensureSafeFile(path);
164
+ const value = JSON.parse(readFileSync(path, 'utf8'));
165
+ if (!plain(value) || !exact(value, ['schema', 'last_attempt_at', 'last_outcome', 'attempted_through'])
166
+ || value.schema !== DELIVERY_SCHEMA
167
+ || (value.last_attempt_at !== null && !Number.isFinite(Date.parse(value.last_attempt_at)))
168
+ || (value.last_outcome !== null && !OUTCOMES.includes(value.last_outcome))
169
+ || !Number.isSafeInteger(value.attempted_through) || value.attempted_through < 0) return emptyDelivery();
170
+ return value;
171
+ } catch {
172
+ return emptyDelivery();
173
+ }
174
+ }
175
+
176
+ function writeDelivery(options, state) {
177
+ const path = deliveryStatePath(options);
178
+ ensureSafeDir(dirname(path));
179
+ const temporary = join(dirname(path), `.runtime-errors-delivery-${process.pid}-${randomBytes(6).toString('hex')}`);
180
+ try {
181
+ writeFileSync(temporary, `${JSON.stringify(state)}\n`, { mode: 0o600, flag: 'wx' });
182
+ replaceStoreFile(temporary, path);
183
+ } finally {
184
+ rmSync(temporary, { force: true });
185
+ }
186
+ }
187
+
188
+ const nowMs = (options) => (options.now ? new Date(options.now).valueOf() : Date.now());
189
+
190
+ /**
191
+ * 自動送信してよい時機か。故障を記録した直後・解決を打った直後・以後のCLI実行が呼ぶ。
192
+ * 未受領が無い、直前の試みから1分未満、同じ中身の送り直しが1時間未満、のどれかなら送らない。
193
+ */
194
+ function throttleReason(options, delivery, highWatermark) {
195
+ if (delivery.last_attempt_at === null) return null;
196
+ const elapsed = nowMs(options) - Date.parse(delivery.last_attempt_at);
197
+ if (elapsed < MIN_ATTEMPT_INTERVAL_MS) return 'attempted_within_a_minute';
198
+ if (delivery.last_outcome !== 'delivered' && delivery.attempted_through === highWatermark
199
+ && elapsed < RETRY_INTERVAL_MS) return 'same_report_retried_within_an_hour';
200
+ return null;
201
+ }
202
+
203
+ export function runtimeErrorReportingStatus(options = {}) {
204
+ const supported = runtimeErrorCollectionSupported(options);
205
+ const enabled = runtimeErrorReportingEnabled(options);
206
+ const credential = supported ? readProductCredential(options) : { status: 'missing' };
207
+ const diagnostics = runtimeErrorsDiagnostics(options);
208
+ const delivery = supported ? readDelivery(options) : emptyDelivery();
209
+ return {
210
+ schema: STATUS_SCHEMA,
211
+ reporting: !supported ? 'unsupported' : enabled ? 'enabled' : 'disabled',
212
+ credential: credential.status === 'ok' ? 'present' : credential.status,
213
+ credential_reason: credential.status === 'unsafe' ? credential.reason : null,
214
+ collection: diagnostics.collection,
215
+ store_status: diagnostics.status,
216
+ pending_count: diagnostics.pending_count,
217
+ last_attempt_at: delivery.last_attempt_at,
218
+ last_outcome: delivery.last_outcome,
219
+ };
220
+ }
221
+
222
+ /** 送信の設定をLattice自身のfileへ書く。dotagentsの設定は読みも書きもしない。 */
223
+ export function setRuntimeErrorReporting(enabled, options = {}) {
224
+ if (typeof enabled !== 'boolean') throw Error('invalid_enabled');
225
+ if (!runtimeErrorCollectionSupported(options)) throw Error('reporting_unsupported');
226
+ const env = options.env ?? process.env;
227
+ const path = options.reportingConfigPath ?? runtimeErrorReportingConfigPath(env);
228
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
229
+ try {
230
+ if (lstatSync(path).isSymbolicLink()) throw Error('config_unsafe');
231
+ } catch (error) {
232
+ if (error?.code !== 'ENOENT') throw error;
233
+ }
234
+ const temporary = join(dirname(path), `.runtime-error-reporting-${process.pid}-${randomBytes(6).toString('hex')}`);
235
+ try {
236
+ writeFileSync(temporary, `${JSON.stringify({ schema: REPORTING_CONFIG_SCHEMA, enabled })}\n`, { mode: 0o600, flag: 'wx' });
237
+ replaceStoreFile(temporary, path);
238
+ } finally {
239
+ rmSync(temporary, { force: true });
240
+ }
241
+ return runtimeErrorReportingStatus(options);
242
+ }
243
+
244
+ function post(url, headers, bodyBytes, timeoutMs) {
245
+ return new Promise((resolve) => {
246
+ const client = url.protocol === 'https:' ? https : http;
247
+ const request = client.request(url, { method: 'POST', headers, timeout: timeoutMs }, (response) => {
248
+ const chunks = [];
249
+ let size = 0;
250
+ response.on('data', (chunk) => {
251
+ size += chunk.length;
252
+ if (size > RESPONSE_MAX_BYTES) { request.destroy(); resolve({ status: null, reason: 'response_too_large' }); return; }
253
+ chunks.push(chunk);
254
+ });
255
+ response.on('end', () => resolve({ status: response.statusCode ?? null, body: Buffer.concat(chunks).toString('utf8') }));
256
+ response.on('error', () => resolve({ status: null, reason: 'response_error' }));
257
+ });
258
+ request.on('timeout', () => { request.destroy(); resolve({ status: null, reason: 'timeout' }); });
259
+ request.on('error', () => resolve({ status: null, reason: 'network_error' }));
260
+ request.end(bodyBytes);
261
+ });
262
+ }
263
+
264
+ const result = (outcome, extra = {}) => ({
265
+ schema: RESULT_SCHEMA, outcome, reason: null, http_status: null,
266
+ sent: { runtime_errors: 0, resolutions: 0 }, acknowledged_through: null, ...extra,
267
+ });
268
+
269
+ function parseJson(text) {
270
+ try {
271
+ const value = JSON.parse(text);
272
+ return plain(value) ? value : null;
273
+ } catch {
274
+ return null;
275
+ }
276
+ }
277
+
278
+ /**
279
+ * 未受領の記録を1回送る。`options.auto`の時だけ送る時機の制限を見る(手で打った`report`は見ない
280
+ * ——多すぎればBugHubが429で断る)。戻り値に秘密・key_id・宛先は入れない。
281
+ */
282
+ export async function reportRuntimeErrors(options = {}) {
283
+ if (!runtimeErrorCollectionSupported(options)) return result('unsupported');
284
+ if (!runtimeErrorReportingEnabled(options)) return result('disabled');
285
+ const credential = readProductCredential(options);
286
+ if (credential.status === 'missing') return result('credential_missing');
287
+ if (credential.status !== 'ok') return result('credential_unsafe', { reason: credential.reason });
288
+
289
+ let pending;
290
+ let delivery;
291
+ try {
292
+ const diagnostics = runtimeErrorsDiagnostics(options);
293
+ if (diagnostics.status !== 'ready') return result('store_unavailable');
294
+ pending = runtimeErrorsSnapshot(diagnostics.acknowledged_through, REPORT_MAX_RECORDS, options);
295
+ delivery = readDelivery(options);
296
+ } catch {
297
+ return result('store_unavailable');
298
+ }
299
+ const sent = { runtime_errors: pending.runtime_errors.length, resolutions: pending.resolutions.length };
300
+ if (sent.runtime_errors + sent.resolutions === 0) {
301
+ return result('nothing_pending', { acknowledged_through: pending.cursor.acknowledged_through });
302
+ }
303
+ if (options.auto === true) {
304
+ const reason = throttleReason(options, delivery, pending.cursor.high_watermark);
305
+ if (reason !== null) return result('throttled', { reason });
306
+ }
307
+
308
+ // `ts`と`observed_at`は同じ時刻から作る(BugHubは10分より離れた組を断る)。
309
+ const ts = Math.floor(nowMs(options) / 1000);
310
+ const report = buildRuntimeErrorReport({ snapshot: pending, version: options.version ?? 'unknown',
311
+ reportId: options.reportId ?? randomUUID(), observedAt: new Date(ts * 1000).toISOString() });
312
+ const bodyBytes = Buffer.from(JSON.stringify(report), 'utf8');
313
+ if (bodyBytes.length > REPORT_MAX_BYTES) return result('rejected', { reason: 'report_too_large', sent });
314
+
315
+ const finish = (outcome, extra = {}) => {
316
+ try {
317
+ writeDelivery(options, { schema: DELIVERY_SCHEMA, last_attempt_at: new Date(nowMs(options)).toISOString(),
318
+ last_outcome: outcome, attempted_through: pending.cursor.high_watermark });
319
+ } catch { /* 送信の結果は返す。時機の記録だけが残らない。 */ }
320
+ return result(outcome, { sent, acknowledged_through: pending.cursor.acknowledged_through, ...extra });
321
+ };
322
+
323
+ const { credential: { url, keyId, secret } } = credential;
324
+ const response = await post(url, {
325
+ 'Content-Type': 'application/json',
326
+ 'Content-Length': String(bodyBytes.length),
327
+ Authorization: `BugHub-HMAC-SHA256 key_id=${keyId}, ts=${ts}, sig=${signReport(secret, String(ts), bodyBytes)}`,
328
+ }, bodyBytes, options.timeoutMs ?? REQUEST_TIMEOUT_MS);
329
+
330
+ if (response.status === null) return finish('unconfirmed', { reason: response.reason });
331
+ const body = parseJson(response.body);
332
+ if (response.status === 200) {
333
+ if (body === null || body.accepted !== true || body.report_id !== report.report_id
334
+ || !receiptSignatureMatches(secret, report.report_id, body.received_at, body.sig)) {
335
+ return finish('unconfirmed', { reason: 'receipt_not_verified', http_status: 200 });
336
+ }
337
+ try {
338
+ const acknowledged = acknowledgeRuntimeErrors(pending.cursor.next, options);
339
+ return finish('delivered', { http_status: 200, acknowledged_through: acknowledged.cursor.acknowledged_through });
340
+ } catch {
341
+ // 届いているが受領済みを残せなかった。次の送信が同じ累計を送り直す(BugHubは二重に数えない)。
342
+ return finish('unconfirmed', { reason: 'ack_not_recorded', http_status: 200 });
343
+ }
344
+ }
345
+ const code = typeof body?.error === 'string' && REJECTION_CODE.test(body.error) ? body.error : 'unrecognized_response';
346
+ if (response.status >= 500) return finish('unconfirmed', { reason: code, http_status: response.status });
347
+ return finish('rejected', { reason: code, http_status: response.status });
348
+ }
349
+
350
+ /**
351
+ * 自動送信の入口が、子processを起こす前に見る軽い判定。設定が無い端末(外の利用者)では、
352
+ * 設定fileの有無を1回見るだけで終わる。合鍵は、送るものがある時だけ確かめる——Windowsでは
353
+ * 合鍵の確認が外のprogramを起こすので、毎回のCLI実行には載せない。
354
+ */
355
+ export function runtimeErrorAutoReportDue(options = {}) {
356
+ if (!runtimeErrorReportingEnabled(options)) return false;
357
+ try {
358
+ const diagnostics = runtimeErrorsDiagnostics(options);
359
+ if (diagnostics.status !== 'ready' || diagnostics.pending_count === 0) return false;
360
+ if (throttleReason(options, readDelivery(options), diagnostics.high_watermark) !== null) return false;
361
+ return readProductCredential(options).status === 'ok';
362
+ } catch {
363
+ return false;
364
+ }
365
+ }
@@ -1,26 +1,32 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
2
  import {
3
- existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync,
3
+ existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync,
4
4
  } from 'node:fs';
5
5
  import { homedir, platform as hostPlatform, arch as hostArch } from 'node:os';
6
6
  import { dirname, join } from 'node:path';
7
7
 
8
+ import {
9
+ daclIsOwnerOnly, daclIsProtected, isAclScratchName, readWindowsDacl, restrictWindowsDirToOwner, windowsSelfSid,
10
+ } from './windows-owner-only.mjs';
11
+
8
12
  /**
9
13
  * opt-in runtime error store(親plan L6要件。Caveat `caveat.runtime_errors.v1` と同型の工場契約)。
10
14
  *
11
- * - collection/reporting分離: 収集可否は工場共有config
12
- * `${XDG_CONFIG_HOME:-~/.config}/dotagents/factory-reporter.json` の `collection.enabled` だけが決める。
13
- * reporting設定はconfig validationの一部だが、本storeは外部送信を一切行わない(送信はdotagents adapter所有)。
14
- * - 既定OFF: config欠落・malformed・disabledでは state も network も触らない。
15
+ * - 収集の有効化: 工場共有config `${XDG_CONFIG_HOME:-~/.config}/dotagents/factory-reporter.json` の
16
+ * `collection.enabled`、またはLattice自身の送信設定(`runtime-error-reporting.json`、ADR 0193)のどちらかが
17
+ * 有効な時だけ収集する。本storeは外部送信を行わない——送信は`runtime-error-reporting.mjs`が持つ。
18
+ * - 既定OFF: どちらの設定も無い・malformed・disabledでは state も network も触らない。
15
19
  * - privacy by design: 保存するのは固定catalogの `error_code` / `message_template` のみ。
16
20
  * 生message・path・引数を保存しない。
17
21
  * - retention: fingerprint集約(同一原因はcount/last_seen更新)+ack済みresolvedの30日compact。
18
- * - POSIX専用: Lattice runtimeはWindows nativeでunsupported(親plan L6)。owner-onlyを証明できない
19
- * 環境では `store_unsafe` でfail closedする。
22
+ * - owner-only: storeは本人だけが触れる形でしか使わない。POSIXはmode(0700・0600)と所有者、Windowsは
23
+ * DACL(本人・SYSTEM・Administratorsだけ、ADR 0194)で確かめ、確かめられなければ `store_unsafe` で
24
+ * fail closedする。
20
25
  */
21
26
 
22
27
  const RUNTIME_ERRORS_SCHEMA = 'lattice.runtime_errors.v1';
23
28
  const DIAGNOSTICS_SCHEMA = 'lattice.runtime_error_diagnostics.v1';
29
+ export const REPORTING_CONFIG_SCHEMA = 'lattice.runtime_error_reporting_config.v1';
24
30
  const PRODUCT = 'lattice';
25
31
  const STATE_VERSION = '1.0';
26
32
  const MAX_RECORDS = 256;
@@ -53,7 +59,9 @@ const plain = (value) => typeof value === 'object' && value !== null && !Array.i
53
59
  const exact = (value, keys) => Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
54
60
  const validTime = (value) => typeof value === 'string' && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
55
61
  const validVersion = (value) => typeof value === 'string' && /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(value);
56
- const validOs = (value) => typeof value === 'string' && ['darwin', 'linux'].includes(value);
62
+ // 収集に対応するOS。storeを本人だけが触れる形で置けると確かめられるものだけを載せる。
63
+ const SUPPORTED_OS = Object.freeze(['darwin', 'linux', 'win32']);
64
+ const validOs = (value) => typeof value === 'string' && SUPPORTED_OS.includes(value);
57
65
  const validArch = (value) => typeof value === 'string' && ['x64', 'arm64', 'arm'].includes(value);
58
66
 
59
67
  export function defaultFactoryReporterConfigPath(env = process.env) {
@@ -61,9 +69,19 @@ export function defaultFactoryReporterConfigPath(env = process.env) {
61
69
  return join(env.XDG_CONFIG_HOME || join(home, '.config'), 'dotagents', 'factory-reporter.json');
62
70
  }
63
71
 
64
- export function runtimeErrorsStatePath(env = process.env) {
65
- const home = env.HOME || homedir();
66
- return join(env.XDG_STATE_HOME || join(home, '.local', 'state'), 'lattice', 'runtime-errors.json');
72
+ /** Windowsで利用者ごとのdataを置く場所(`%LOCALAPPDATA%`)。 */
73
+ export function windowsLocalAppData(env = process.env) {
74
+ return env.LOCALAPPDATA || join(env.USERPROFILE || homedir(), 'AppData', 'Local');
75
+ }
76
+
77
+ /**
78
+ * storeの置き場。Windowsは`%LOCALAPPDATA%\Lattice\runtime-errors\`という専用のフォルダへ置く
79
+ * ——`%LOCALAPPDATA%\Lattice`には他の機能のfileがあり、フォルダごと本人だけに絞れない。
80
+ */
81
+ export function runtimeErrorsStatePath(env = process.env, platform = hostPlatform()) {
82
+ if (env.XDG_STATE_HOME) return join(env.XDG_STATE_HOME, 'lattice', 'runtime-errors.json');
83
+ if (platform === 'win32') return join(windowsLocalAppData(env), 'Lattice', 'runtime-errors', 'runtime-errors.json');
84
+ return join(env.HOME || homedir(), '.local', 'state', 'lattice', 'runtime-errors.json');
67
85
  }
68
86
 
69
87
  function canonicalReporting(value) {
@@ -76,13 +94,47 @@ function canonicalReporting(value) {
76
94
  return !value.enabled || (value.endpoint !== undefined && value.credential_file !== undefined);
77
95
  }
78
96
 
79
- // このOSで収集に対応するか。Windowsはstoreの所有者と権限をPOSIXの形で確かめられない(`ensureSafeDir`)ので
80
- // 対応しない。設定が有効でも記録は作らず、工場へは`disabled`でなく`unsupported`と答える——設定は有効なのに
81
- // 製品が黙って無効と答えると、受け側は故障と区別できない。
82
- const collectionSupported = (options = {}) => (options.platform ?? hostPlatform()) !== 'win32';
97
+ // このOSで収集に対応するか。対応しないOSでは、設定が有効でも記録は作らず、`disabled`でなく`unsupported`と
98
+ // 答える——設定は有効なのに製品が黙って無効と答えると、受け側は故障と区別できない。
99
+ const collectionSupported = (options = {}) => SUPPORTED_OS.includes(options.platform ?? hostPlatform());
83
100
  const inactiveCollection = (options = {}) => (collectionSupported(options) ? 'disabled' : 'unsupported');
84
101
 
102
+ export function runtimeErrorCollectionSupported(options = {}) {
103
+ return collectionSupported(options);
104
+ }
105
+
106
+ export function runtimeErrorReportingConfigPath(env = process.env, platform = hostPlatform()) {
107
+ if (env.XDG_CONFIG_HOME) return join(env.XDG_CONFIG_HOME, 'lattice', 'runtime-error-reporting.json');
108
+ if (platform === 'win32') return join(windowsLocalAppData(env), 'Lattice', 'runtime-error-reporting.json');
109
+ return join(env.HOME || homedir(), '.config', 'lattice', 'runtime-error-reporting.json');
110
+ }
111
+
112
+ /**
113
+ * Lattice自身の送信設定(ADR 0193)。端末で`runtime-errors reporting enable`を打った時だけ有効になる。
114
+ * `LATTICE_RUNTIME_ERROR_REPORTING=0`は既定の置き場を読まない——試験と自動化が、その端末の本物の設定を
115
+ * 拾って送信しないための口。`options.reportingConfigPath`を明示した呼び出しはそのfileを読む。
116
+ */
117
+ export function runtimeErrorReportingEnabled(options = {}) {
118
+ if (!collectionSupported(options)) return false;
119
+ const env = options.env ?? process.env;
120
+ if (options.reportingConfigPath === undefined && env.LATTICE_RUNTIME_ERROR_REPORTING === '0') return false;
121
+ try {
122
+ const path = options.reportingConfigPath ?? runtimeErrorReportingConfigPath(env);
123
+ const stats = lstatSync(path);
124
+ if (!stats.isFile() || stats.isSymbolicLink()) return false;
125
+ const config = JSON.parse(readFileSync(path, 'utf8'));
126
+ return plain(config) && exact(config, ['schema', 'enabled'])
127
+ && config.schema === REPORTING_CONFIG_SCHEMA && config.enabled === true;
128
+ } catch {
129
+ return false;
130
+ }
131
+ }
132
+
85
133
  function collectionEnabled(options = {}) {
134
+ return factoryCollectionEnabled(options) || runtimeErrorReportingEnabled(options);
135
+ }
136
+
137
+ function factoryCollectionEnabled(options = {}) {
86
138
  if (!collectionSupported(options)) return false;
87
139
  const env = options.env ?? process.env;
88
140
  try {
@@ -140,18 +192,63 @@ function assertPosix(info, mode) {
140
192
  if ((info.mode & 0o777) !== mode || (typeof process.getuid === 'function' && info.uid !== process.getuid())) throw Error('store_unsafe');
141
193
  }
142
194
 
143
- function ensureSafeDir(dir) {
144
- if (hostPlatform() === 'win32') throw Error('store_unsafe');
195
+ const windows = () => hostPlatform() === 'win32';
196
+
197
+ /**
198
+ * Windowsのフォルダが本人・SYSTEM・Administratorsだけのもので、親からの継承を切ってあるか確かめる。
199
+ * 作ったばかりのフォルダは親の権限を継いでいるので、絞る。ただし、他のaccountが触れる形なのに中身がある
200
+ * フォルダは、その中身を信用できないので、絞らずに止める。
201
+ */
202
+ function ensureWindowsDirOwnerOnly(dir) {
203
+ const sid = windowsSelfSid();
204
+ const settled = (sddl) => daclIsOwnerOnly(sddl, sid) && daclIsProtected(sddl);
205
+ const current = readWindowsDacl(dir, dir);
206
+ if (settled(current)) return;
207
+ if (!daclIsOwnerOnly(current, sid) && readdirSync(dir).some((name) => !isAclScratchName(name))) throw Error('store_unsafe');
208
+ restrictWindowsDirToOwner(dir, sid);
209
+ if (!settled(readWindowsDacl(dir, dir))) throw Error('store_unsafe');
210
+ }
211
+
212
+ export function ensureSafeDir(dir) {
145
213
  mkdirSync(dir, { recursive: true, mode: 0o700 });
146
214
  const stats = lstatSync(dir);
215
+ // Windowsのjunctionも`isSymbolicLink`で落ちる。
147
216
  if (!stats.isDirectory() || stats.isSymbolicLink()) throw Error('store_unsafe');
148
- assertPosix(stats, 0o700);
217
+ if (windows()) ensureWindowsDirOwnerOnly(dir);
218
+ else assertPosix(stats, 0o700);
149
219
  }
150
220
 
151
- function ensureSafeFile(path) {
221
+ /**
222
+ * `scratchDir`はWindowsだけが使う: DACLの読み取りが出力fileを置くフォルダ。既定はそのfileのフォルダ。
223
+ * storeの外のfile(合鍵)を確かめる時は、絞ってあるstoreのフォルダを渡す。
224
+ */
225
+ export function ensureSafeFile(path, scratchDir = dirname(path)) {
152
226
  const stats = lstatSync(path);
153
227
  if (!stats.isFile() || stats.isSymbolicLink()) throw Error('store_unsafe');
154
- assertPosix(statSync(path), 0o600);
228
+ if (!windows()) {
229
+ assertPosix(statSync(path), 0o600);
230
+ return;
231
+ }
232
+ const sid = windowsSelfSid();
233
+ // 出力fileを置くフォルダを他のaccountが書けるなら、読んだDACLを信用できない。先にそれを確かめる。
234
+ if (!daclIsOwnerOnly(readWindowsDacl(scratchDir, scratchDir), sid)
235
+ || !daclIsOwnerOnly(readWindowsDacl(path, scratchDir), sid)) throw Error('store_unsafe');
236
+ }
237
+
238
+ /**
239
+ * 一時fileを本番の名前へ置き換える。Windowsは、読み手が開いている宛先へのrenameを一時的に断るので、
240
+ * 少し待って繰り返す(`fs-publish.mjs`と同じ事情)。
241
+ */
242
+ export function replaceStoreFile(source, destination) {
243
+ for (let attempt = 0; ; attempt++) {
244
+ try {
245
+ renameSync(source, destination);
246
+ return;
247
+ } catch (error) {
248
+ if (!windows() || !['EPERM', 'EACCES', 'EBUSY'].includes(error?.code) || attempt >= 7) throw error;
249
+ sleepSync(Math.min(64, 2 ** attempt));
250
+ }
251
+ }
155
252
  }
156
253
 
157
254
  const RECORD_KEYS = Object.freeze(['product', 'product_version', 'component', 'error_code', 'message_template', 'severity', 'fingerprint', 'count', 'first_seen', 'last_seen', 'state_schema_version', 'os', 'arch', 'status', 'resolved_at', 'reason_code', 'sequence']);
@@ -200,8 +297,9 @@ function writeStore(path, store) {
200
297
  const temporary = join(dirname(path), `.runtime-errors-${process.pid}-${randomBytes(6).toString('hex')}`);
201
298
  try {
202
299
  writeFileSync(temporary, `${JSON.stringify(store)}\n`, { mode: 0o600, flag: 'wx' });
203
- assertPosix(statSync(temporary), 0o600);
204
- renameSync(temporary, path);
300
+ // Windowsの一時fileはフォルダの権限を引き継ぐ。置き換えた後の`ensureSafeFile`が確かめる。
301
+ if (!windows()) assertPosix(statSync(temporary), 0o600);
302
+ replaceStoreFile(temporary, path);
205
303
  ensureSafeFile(path);
206
304
  } finally {
207
305
  rmSync(temporary, { force: true });
@@ -221,9 +319,15 @@ function lock(path, fn) {
221
319
  writeFileSync(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' });
222
320
  break;
223
321
  } catch (error) {
224
- if (!plain(error) || error.code !== 'EEXIST') throw error;
322
+ // Windowsは、消している最中のlockと同じ名前の作成を`EEXIST`でなく`EPERM`で断る。
323
+ if (!plain(error) || !(error.code === 'EEXIST' || (windows() && ['EPERM', 'EACCES'].includes(error.code)))) throw error;
225
324
  let age = 0;
226
- try { age = Date.now() - lstatSync(lockPath).mtimeMs; } catch { continue; }
325
+ try {
326
+ age = Date.now() - lstatSync(lockPath).mtimeMs;
327
+ } catch {
328
+ if (Date.now() >= deadline) throw Error('store_locked');
329
+ continue;
330
+ }
227
331
  // crash残置lockの恒久ロックを避ける唯一の明示救済。閾値未満は正当な並行writerとして待つ。
228
332
  if (age > LOCK_STALE_MS) { try { unlinkSync(lockPath); } catch {} continue; }
229
333
  if (Date.now() >= deadline) throw Error('store_locked');
@@ -0,0 +1,95 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { readFileSync, rmSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+
6
+ /**
7
+ * Windowsで「本人・SYSTEM・Administratorsだけが触れる」ことを確かめる(ADR 0194)。
8
+ *
9
+ * POSIXのmode・uidに当たるものがWindowsには無いので、DACL(誰に何を許すかの一覧)を読む。
10
+ * - 読み方は`icacls <path> /save <file>`。SDDLという、表示言語に依らない形で返る。
11
+ * `icacls <path>`の画面表示は名前が表示言語で変わるので使わない。
12
+ * - 自分のSIDは`whoami /user`で得る。どちらも`%SystemRoot%\System32`の実物を絶対pathで呼ぶ
13
+ * ——PATHに置かれた同名のprogramへ答えを作らせない。
14
+ * - 所有者は読まない(`icacls`は返さない)。空のフォルダを絞る時に所有者を本人へ替える。
15
+ */
16
+
17
+ const SELF_SID = /^S-1-[0-9]+(?:-[0-9]+)+$/;
18
+ const SYSTEM_SID = 'S-1-5-18';
19
+ const ADMINISTRATORS_SID = 'S-1-5-32-544';
20
+ const COMMAND_TIMEOUT_MS = 10_000;
21
+ // `icacls /save`の出力fileの名前。フォルダが空かを見る時、同時に走る別processのこのfileは数えない。
22
+ const SCRATCH_NAME = /^\.acl-\d+-[0-9a-f]{12}$/;
23
+
24
+ let cachedSelfSid = null;
25
+
26
+ function systemTool(name, env = process.env) {
27
+ return join(env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows', 'System32', name);
28
+ }
29
+
30
+ function run(tool, args) {
31
+ const result = spawnSync(systemTool(tool), args, { encoding: 'utf8', windowsHide: true, timeout: COMMAND_TIMEOUT_MS });
32
+ if (result.error || result.status !== 0) throw Error('store_unsafe');
33
+ return result.stdout;
34
+ }
35
+
36
+ /** このprocessを動かしている利用者のSID。process中は変わらないので1回だけ聞く。 */
37
+ export function windowsSelfSid() {
38
+ if (cachedSelfSid === null) {
39
+ const sid = run('whoami.exe', ['/user', '/fo', 'csv', '/nh']).trim().split(',').at(-1).replaceAll('"', '');
40
+ if (!SELF_SID.test(sid)) throw Error('store_unsafe');
41
+ cachedSelfSid = sid;
42
+ }
43
+ return cachedSelfSid;
44
+ }
45
+
46
+ /**
47
+ * SDDLのDACLが、本人・SYSTEM・Administratorsへの許可だけで出来ているか。
48
+ * 読めない形(DACL無し・拒否・条件つき・object用のACE)はすべて「確かめられない」として通さない。
49
+ */
50
+ export function daclIsOwnerOnly(sddl, selfSid) {
51
+ if (typeof sddl !== 'string' || !SELF_SID.test(selfSid)) return false;
52
+ const match = /^D:(?:P|AR|AI)*((?:\([^()]*\))+)$/.exec(sddl);
53
+ if (match === null) return false;
54
+ const trusted = new Set([selfSid, 'SY', SYSTEM_SID, 'BA', ADMINISTRATORS_SID]);
55
+ return match[1].slice(1, -1).split(')(').every((ace) => {
56
+ const fields = ace.split(';');
57
+ return fields.length === 6 && fields[0] === 'A' && fields[3] === '' && fields[4] === '' && trusted.has(fields[5]);
58
+ });
59
+ }
60
+
61
+ /** 親からの継承を切ってあるか(`P`)。切ってあれば、親の権限が後から変わっても降りてこない。 */
62
+ export function daclIsProtected(sddl) {
63
+ return typeof sddl === 'string' && /^D:(?:AR|AI)*P/.test(sddl);
64
+ }
65
+
66
+ export const isAclScratchName = (name) => SCRATCH_NAME.test(name);
67
+
68
+ /**
69
+ * `path`のDACLをSDDLで読む。`icacls`はfileへしか書き出せないので、出力は`scratchDir`へ置いてすぐ消す。
70
+ * `scratchDir`には、他のaccountが書けないフォルダ(絞ったstoreのフォルダ)を渡す——他のaccountが書ける
71
+ * 場所へ置くと、読む前に中身を書き換えられる。
72
+ */
73
+ export function readWindowsDacl(path, scratchDir) {
74
+ const scratch = join(scratchDir, `.acl-${process.pid}-${randomBytes(6).toString('hex')}`);
75
+ try {
76
+ run('icacls.exe', [path, '/save', scratch]);
77
+ const lines = readFileSync(scratch, 'utf16le').replaceAll('\ufeff', '').split(/\r?\n/).filter((line) => line !== '');
78
+ // 1行目が名前、2行目がSDDL。それ以外の形は読めなかったものとして扱う。
79
+ if (lines.length !== 2) throw Error('store_unsafe');
80
+ return lines[1];
81
+ } finally {
82
+ rmSync(scratch, { force: true });
83
+ }
84
+ }
85
+
86
+ /**
87
+ * フォルダを本人・SYSTEM・Administratorsだけに絞る。親からの継承を切り、所有者を本人にする。
88
+ * 中に作るfileはこの権限を引き継ぎ、renameで置き換えた後も保たれる。
89
+ * 誰かがそのフォルダへ直接足した権限は消さない——呼び出し側が絞った後のDACLを読み直して、残っていれば止める。
90
+ */
91
+ export function restrictWindowsDirToOwner(dir, selfSid) {
92
+ const sids = [...new Set([selfSid, SYSTEM_SID, ADMINISTRATORS_SID])];
93
+ run('icacls.exe', [dir, '/inheritance:r', '/grant:r', ...sids.map((sid) => `*${sid}:(OI)(CI)F`)]);
94
+ run('icacls.exe', [dir, '/setowner', `*${selfSid}`]);
95
+ }