@quolu/lattice 0.74.0 → 0.76.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 +9 -2
- package/package.json +1 -1
- package/src/cli-help.mjs +2 -1
- package/src/runtime-error-reporting.mjs +105 -44
- package/src/runtime-errors.mjs +91 -22
- package/src/windows-owner-only.mjs +95 -0
package/bin/lattice.mjs
CHANGED
|
@@ -185,8 +185,9 @@ await scheduleRuntimeErrorReport();
|
|
|
185
185
|
* 送信を有効にしていない端末では、設定fileの有無を1回見るだけで終わる。hookは打鍵ごとに走るので見ない。
|
|
186
186
|
*/
|
|
187
187
|
async function scheduleRuntimeErrorReport() {
|
|
188
|
+
// `reporting verify`の直後は送らない。受け口は1分に1回まで受けるので、続けて送ると断られる。
|
|
188
189
|
if (help !== null || args[0] === '--version' || args[0] === 'hooks'
|
|
189
|
-
|| (args[0] === 'runtime-errors' && args[1] === 'report')) return;
|
|
190
|
+
|| (args[0] === 'runtime-errors' && (args[1] === 'report' || (args[1] === 'reporting' && args[2] === 'verify')))) return;
|
|
190
191
|
try {
|
|
191
192
|
const { runtimeErrorReportingEnabled } = await import('../src/runtime-errors.mjs');
|
|
192
193
|
if (!runtimeErrorReportingEnabled()) return;
|
|
@@ -222,7 +223,7 @@ function workingDirectory() {
|
|
|
222
223
|
async function runRuntimeErrorsCli(rest) {
|
|
223
224
|
const runtimeErrors = await import('../src/runtime-errors.mjs');
|
|
224
225
|
const usage = () => {
|
|
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`);
|
|
226
|
+
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|verify>> --json' })}\n`);
|
|
226
227
|
return 2;
|
|
227
228
|
};
|
|
228
229
|
const options = { version: packageJson.version };
|
|
@@ -251,6 +252,12 @@ async function runRuntimeErrorsCli(rest) {
|
|
|
251
252
|
result = runtimeErrors.setRuntimeErrorStatus(words[1], words[0] === 'resolve' ? 'resolved' : 'open', options);
|
|
252
253
|
} else if (words[0] === 'compact' && words.length === 1) {
|
|
253
254
|
result = runtimeErrors.compactRuntimeErrors(options);
|
|
255
|
+
} else if (words[0] === 'reporting' && words.length === 2 && words[1] === 'verify') {
|
|
256
|
+
// 空の報告を1通送って経路を確かめる。受領まで確かめられた時だけexit 0。
|
|
257
|
+
const reporting = await import('../src/runtime-error-reporting.mjs');
|
|
258
|
+
result = await reporting.verifyRuntimeErrorReporting(options);
|
|
259
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
260
|
+
return result.outcome === 'verified' ? 0 : 1;
|
|
254
261
|
} else if (words[0] === 'reporting' && words.length === 2 && ['status', 'enable', 'disable'].includes(words[1])) {
|
|
255
262
|
const reporting = await import('../src/runtime-error-reporting.mjs');
|
|
256
263
|
result = words[1] === 'status' ? reporting.runtimeErrorReportingStatus(options)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quolu/lattice",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.76.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
|
@@ -198,6 +198,7 @@ Commands:
|
|
|
198
198
|
compact
|
|
199
199
|
report 未受領の記録をBugHubの受け口へ送る(送信を有効にした端末だけ)
|
|
200
200
|
reporting <status|enable|disable>
|
|
201
|
+
reporting verify 空の報告を1通送り、合鍵と受け口までの経路を確かめる(記録には触れない)
|
|
201
202
|
`,
|
|
202
203
|
bridge: `Usage: lattice bridge <command> [options] --json
|
|
203
204
|
|
|
@@ -315,7 +316,7 @@ const SUBCOMMAND_USAGE = Object.freeze({
|
|
|
315
316
|
'runtime-errors reopen': 'runtime-errors reopen <fingerprint> --json',
|
|
316
317
|
'runtime-errors compact': 'runtime-errors compact --json',
|
|
317
318
|
'runtime-errors report': 'runtime-errors report --json',
|
|
318
|
-
'runtime-errors reporting': 'runtime-errors reporting <status|enable|disable> --json',
|
|
319
|
+
'runtime-errors reporting': 'runtime-errors reporting <status|enable|disable|verify> --json',
|
|
319
320
|
'bridge setup': 'bridge setup --listen <IP> [--port <49152..65535|auto>] [--dashboard|--upstream <URL>] [--hub <URL>|none] [--allow-host <host>...] --json',
|
|
320
321
|
'bridge reconfigure': 'bridge reconfigure [--listen <IP>] [--port <49152..65535|auto>] [--dashboard|--upstream <URL>] [--hub <URL>|none] [--allow-host <host>...] --json',
|
|
321
322
|
'bridge status': 'bridge status --json',
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
2
|
import {
|
|
3
|
-
existsSync, lstatSync, mkdirSync, readFileSync,
|
|
3
|
+
existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync,
|
|
4
4
|
} from 'node:fs';
|
|
5
5
|
import http from 'node:http';
|
|
6
6
|
import https from 'node:https';
|
|
7
|
-
import { homedir } from 'node:os';
|
|
7
|
+
import { homedir, platform as hostPlatform } from 'node:os';
|
|
8
8
|
import { dirname, join } from 'node:path';
|
|
9
9
|
|
|
10
10
|
import {
|
|
@@ -12,12 +12,14 @@ import {
|
|
|
12
12
|
acknowledgeRuntimeErrors,
|
|
13
13
|
ensureSafeDir,
|
|
14
14
|
ensureSafeFile,
|
|
15
|
+
replaceStoreFile,
|
|
15
16
|
runtimeErrorCollectionSupported,
|
|
16
17
|
runtimeErrorReportingConfigPath,
|
|
17
18
|
runtimeErrorReportingEnabled,
|
|
18
19
|
runtimeErrorsDiagnostics,
|
|
19
20
|
runtimeErrorsSnapshot,
|
|
20
21
|
runtimeErrorsStatePath,
|
|
22
|
+
windowsLocalAppData,
|
|
21
23
|
} from './runtime-errors.mjs';
|
|
22
24
|
|
|
23
25
|
/**
|
|
@@ -29,6 +31,8 @@ import {
|
|
|
29
31
|
* - 受領済みにするのは、200・`accepted: true`・`report_id`一致・応答の署名一致がそろった時だけ。
|
|
30
32
|
* そろわなければ「届いたか不明」として記録を未受領のまま残し、後から今の累計を送り直す。
|
|
31
33
|
* - 送るのは`runtime-errors snapshot`が出す項目だけ。message本文・path・引数・stackは記録に無い。
|
|
34
|
+
* - `reporting verify`は、記録を載せない空の報告を1通送って、合鍵・署名・受け口までの経路を確かめる。
|
|
35
|
+
* 受け口は受領を記録するだけで、issueも通知も動かない。storeの記録と受領の印には触れない。
|
|
32
36
|
*/
|
|
33
37
|
|
|
34
38
|
const REPORT_SCHEMA_VERSION = '1.0';
|
|
@@ -51,13 +55,34 @@ const OUTCOMES = Object.freeze(['delivered', 'nothing_pending', 'disabled', 'uns
|
|
|
51
55
|
const plain = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
52
56
|
const exact = (value, keys) => Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
|
|
53
57
|
|
|
54
|
-
|
|
55
|
-
|
|
58
|
+
/** 合鍵の置き場(BugHubの契約)。Windowsは`%LOCALAPPDATA%\bughub\product-credentials\`。 */
|
|
59
|
+
export function productCredentialPath(env = process.env, platform = hostPlatform()) {
|
|
60
|
+
const base = platform === 'win32' ? join(windowsLocalAppData(env), 'bughub') : join(env.HOME || homedir(), '.config', 'bughub');
|
|
61
|
+
return join(base, 'product-credentials', `${PRODUCT_ID}.json`);
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
65
|
+
* Windowsの合鍵が本人・SYSTEM・Administratorsだけのものか。DACLの読み取りは出力fileを置く場所が要るので、
|
|
66
|
+
* 絞ったstoreのフォルダを使う(無ければ作る)。合鍵のフォルダへは何も書かない。
|
|
67
|
+
*/
|
|
68
|
+
function windowsCredentialUnsafeReason(path, options) {
|
|
69
|
+
const storeDir = dirname(options.storePath ?? runtimeErrorsStatePath(options.env ?? process.env));
|
|
70
|
+
try {
|
|
71
|
+
ensureSafeDir(storeDir);
|
|
72
|
+
} catch {
|
|
73
|
+
return 'acl_unverifiable';
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
ensureSafeFile(path, storeDir);
|
|
77
|
+
return null;
|
|
78
|
+
} catch {
|
|
79
|
+
return 'acl_not_owner_only';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 合鍵のfileを読む。BugHubの持ち主が置く形(symlinkでなく、POSIXは本人所有・0600、Windowsは
|
|
85
|
+
* 本人・SYSTEM・Administratorsだけに権限)以外は使わない。秘密は戻り値の中だけに留め、結果や記録へ写さない。
|
|
61
86
|
*/
|
|
62
87
|
export function readProductCredential(options = {}) {
|
|
63
88
|
const path = options.credentialPath ?? productCredentialPath(options.env ?? process.env);
|
|
@@ -68,8 +93,13 @@ export function readProductCredential(options = {}) {
|
|
|
68
93
|
return error?.code === 'ENOENT' ? { status: 'missing' } : { status: 'unsafe', reason: 'unreadable' };
|
|
69
94
|
}
|
|
70
95
|
if (!stats.isFile() || stats.isSymbolicLink()) return { status: 'unsafe', reason: 'not_regular_file' };
|
|
71
|
-
if ((
|
|
72
|
-
|
|
96
|
+
if (hostPlatform() === 'win32') {
|
|
97
|
+
const reason = windowsCredentialUnsafeReason(path, options);
|
|
98
|
+
if (reason !== null) return { status: 'unsafe', reason };
|
|
99
|
+
} else {
|
|
100
|
+
if ((stats.mode & 0o777) !== 0o600) return { status: 'unsafe', reason: 'mode_not_0600' };
|
|
101
|
+
if (typeof process.getuid === 'function' && stats.uid !== process.getuid()) return { status: 'unsafe', reason: 'owner_mismatch' };
|
|
102
|
+
}
|
|
73
103
|
let value;
|
|
74
104
|
try {
|
|
75
105
|
value = JSON.parse(readFileSync(path, 'utf8'));
|
|
@@ -151,7 +181,7 @@ function writeDelivery(options, state) {
|
|
|
151
181
|
const temporary = join(dirname(path), `.runtime-errors-delivery-${process.pid}-${randomBytes(6).toString('hex')}`);
|
|
152
182
|
try {
|
|
153
183
|
writeFileSync(temporary, `${JSON.stringify(state)}\n`, { mode: 0o600, flag: 'wx' });
|
|
154
|
-
|
|
184
|
+
replaceStoreFile(temporary, path);
|
|
155
185
|
} finally {
|
|
156
186
|
rmSync(temporary, { force: true });
|
|
157
187
|
}
|
|
@@ -206,7 +236,7 @@ export function setRuntimeErrorReporting(enabled, options = {}) {
|
|
|
206
236
|
const temporary = join(dirname(path), `.runtime-error-reporting-${process.pid}-${randomBytes(6).toString('hex')}`);
|
|
207
237
|
try {
|
|
208
238
|
writeFileSync(temporary, `${JSON.stringify({ schema: REPORTING_CONFIG_SCHEMA, enabled })}\n`, { mode: 0o600, flag: 'wx' });
|
|
209
|
-
|
|
239
|
+
replaceStoreFile(temporary, path);
|
|
210
240
|
} finally {
|
|
211
241
|
rmSync(temporary, { force: true });
|
|
212
242
|
}
|
|
@@ -247,6 +277,41 @@ function parseJson(text) {
|
|
|
247
277
|
}
|
|
248
278
|
}
|
|
249
279
|
|
|
280
|
+
/**
|
|
281
|
+
* 報告を1通、署名して送り、受領を確かめる。`accepted`を返すのは、200・`accepted: true`・`report_id`一致・
|
|
282
|
+
* 応答の署名一致がそろった時だけ。戻り値に秘密・key_id・宛先は入れない。
|
|
283
|
+
*/
|
|
284
|
+
async function sendReport({ url, keyId, secret }, snapshot, options) {
|
|
285
|
+
const now = nowMs(options);
|
|
286
|
+
const ts = Math.floor(now / 1000);
|
|
287
|
+
// `observed_at`は、載せる記録のどの時刻よりも前にしない(BugHubは観測より後の記録を断る)。秒へ切り捨てると、
|
|
288
|
+
// 記録と同じ1秒の中で送った時に記録より前へずれる。`ts`とは同じ時刻から作り、差はBugHubの許す幅(10分)に収まる。
|
|
289
|
+
const observed = Math.max(now, ...snapshot.runtime_errors.map((record) => Date.parse(record.last_seen)),
|
|
290
|
+
...snapshot.resolutions.map((resolution) => Date.parse(resolution.resolved_at)));
|
|
291
|
+
const report = buildRuntimeErrorReport({ snapshot, version: options.version ?? 'unknown',
|
|
292
|
+
reportId: options.reportId ?? randomUUID(), observedAt: new Date(observed).toISOString() });
|
|
293
|
+
const bodyBytes = Buffer.from(JSON.stringify(report), 'utf8');
|
|
294
|
+
if (bodyBytes.length > REPORT_MAX_BYTES) return { outcome: 'too_large', reason: 'report_too_large', http_status: null };
|
|
295
|
+
|
|
296
|
+
const response = await post(url, {
|
|
297
|
+
'Content-Type': 'application/json',
|
|
298
|
+
'Content-Length': String(bodyBytes.length),
|
|
299
|
+
Authorization: `BugHub-HMAC-SHA256 key_id=${keyId}, ts=${ts}, sig=${signReport(secret, String(ts), bodyBytes)}`,
|
|
300
|
+
}, bodyBytes, options.timeoutMs ?? REQUEST_TIMEOUT_MS);
|
|
301
|
+
|
|
302
|
+
if (response.status === null) return { outcome: 'unconfirmed', reason: response.reason, http_status: null };
|
|
303
|
+
const body = parseJson(response.body);
|
|
304
|
+
if (response.status === 200) {
|
|
305
|
+
if (body === null || body.accepted !== true || body.report_id !== report.report_id
|
|
306
|
+
|| !receiptSignatureMatches(secret, report.report_id, body.received_at, body.sig)) {
|
|
307
|
+
return { outcome: 'unconfirmed', reason: 'receipt_not_verified', http_status: 200 };
|
|
308
|
+
}
|
|
309
|
+
return { outcome: 'accepted', reason: null, http_status: 200 };
|
|
310
|
+
}
|
|
311
|
+
const code = typeof body?.error === 'string' && REJECTION_CODE.test(body.error) ? body.error : 'unrecognized_response';
|
|
312
|
+
return { outcome: response.status >= 500 ? 'unconfirmed' : 'rejected', reason: code, http_status: response.status };
|
|
313
|
+
}
|
|
314
|
+
|
|
250
315
|
/**
|
|
251
316
|
* 未受領の記録を1回送る。`options.auto`の時だけ送る時機の制限を見る(手で打った`report`は見ない
|
|
252
317
|
* ——多すぎればBugHubが429で断る)。戻り値に秘密・key_id・宛先は入れない。
|
|
@@ -277,13 +342,6 @@ export async function reportRuntimeErrors(options = {}) {
|
|
|
277
342
|
if (reason !== null) return result('throttled', { reason });
|
|
278
343
|
}
|
|
279
344
|
|
|
280
|
-
// `ts`と`observed_at`は同じ時刻から作る(BugHubは10分より離れた組を断る)。
|
|
281
|
-
const ts = Math.floor(nowMs(options) / 1000);
|
|
282
|
-
const report = buildRuntimeErrorReport({ snapshot: pending, version: options.version ?? 'unknown',
|
|
283
|
-
reportId: options.reportId ?? randomUUID(), observedAt: new Date(ts * 1000).toISOString() });
|
|
284
|
-
const bodyBytes = Buffer.from(JSON.stringify(report), 'utf8');
|
|
285
|
-
if (bodyBytes.length > REPORT_MAX_BYTES) return result('rejected', { reason: 'report_too_large', sent });
|
|
286
|
-
|
|
287
345
|
const finish = (outcome, extra = {}) => {
|
|
288
346
|
try {
|
|
289
347
|
writeDelivery(options, { schema: DELIVERY_SCHEMA, last_attempt_at: new Date(nowMs(options)).toISOString(),
|
|
@@ -292,44 +350,47 @@ export async function reportRuntimeErrors(options = {}) {
|
|
|
292
350
|
return result(outcome, { sent, acknowledged_through: pending.cursor.acknowledged_through, ...extra });
|
|
293
351
|
};
|
|
294
352
|
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
if (response.status === 200) {
|
|
305
|
-
if (body === null || body.accepted !== true || body.report_id !== report.report_id
|
|
306
|
-
|| !receiptSignatureMatches(secret, report.report_id, body.received_at, body.sig)) {
|
|
307
|
-
return finish('unconfirmed', { reason: 'receipt_not_verified', http_status: 200 });
|
|
308
|
-
}
|
|
309
|
-
try {
|
|
310
|
-
const acknowledged = acknowledgeRuntimeErrors(pending.cursor.next, options);
|
|
311
|
-
return finish('delivered', { http_status: 200, acknowledged_through: acknowledged.cursor.acknowledged_through });
|
|
312
|
-
} catch {
|
|
313
|
-
// 届いているが受領済みを残せなかった。次の送信が同じ累計を送り直す(BugHubは二重に数えない)。
|
|
314
|
-
return finish('unconfirmed', { reason: 'ack_not_recorded', http_status: 200 });
|
|
315
|
-
}
|
|
353
|
+
const answer = await sendReport(credential.credential, pending, options);
|
|
354
|
+
if (answer.outcome === 'too_large') return result('rejected', { reason: 'report_too_large', sent });
|
|
355
|
+
if (answer.outcome !== 'accepted') return finish(answer.outcome, { reason: answer.reason, http_status: answer.http_status });
|
|
356
|
+
try {
|
|
357
|
+
const acknowledged = acknowledgeRuntimeErrors(pending.cursor.next, options);
|
|
358
|
+
return finish('delivered', { http_status: 200, acknowledged_through: acknowledged.cursor.acknowledged_through });
|
|
359
|
+
} catch {
|
|
360
|
+
// 届いているが受領済みを残せなかった。次の送信が同じ累計を送り直す(BugHubは二重に数えない)。
|
|
361
|
+
return finish('unconfirmed', { reason: 'ack_not_recorded', http_status: 200 });
|
|
316
362
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* 記録を載せない空の報告を1通送り、合鍵・署名・受け口までの経路を確かめる(`reporting verify`)。
|
|
367
|
+
* 送る条件は`report`と同じで、送信を有効にしていない端末と合鍵の無い端末はnetworkへ触れない。
|
|
368
|
+
* storeの記録・受領の印・送る時機の記録には触れない。受け口は端末×製品ごとに1分に1回まで受けるので、
|
|
369
|
+
* 直前に送っていれば429で断られる。
|
|
370
|
+
*/
|
|
371
|
+
export async function verifyRuntimeErrorReporting(options = {}) {
|
|
372
|
+
if (!runtimeErrorCollectionSupported(options)) return result('unsupported');
|
|
373
|
+
if (!runtimeErrorReportingEnabled(options)) return result('disabled');
|
|
374
|
+
const credential = readProductCredential(options);
|
|
375
|
+
if (credential.status === 'missing') return result('credential_missing');
|
|
376
|
+
if (credential.status !== 'ok') return result('credential_unsafe', { reason: credential.reason });
|
|
377
|
+
const answer = await sendReport(credential.credential, { runtime_errors: [], resolutions: [] }, options);
|
|
378
|
+
if (answer.outcome === 'accepted') return result('verified', { http_status: 200 });
|
|
379
|
+
return result(answer.outcome, { reason: answer.reason, http_status: answer.http_status });
|
|
320
380
|
}
|
|
321
381
|
|
|
322
382
|
/**
|
|
323
383
|
* 自動送信の入口が、子processを起こす前に見る軽い判定。設定が無い端末(外の利用者)では、
|
|
324
|
-
* 設定fileの有無を1
|
|
384
|
+
* 設定fileの有無を1回見るだけで終わる。合鍵は、送るものがある時だけ確かめる——Windowsでは
|
|
385
|
+
* 合鍵の確認が外のprogramを起こすので、毎回のCLI実行には載せない。
|
|
325
386
|
*/
|
|
326
387
|
export function runtimeErrorAutoReportDue(options = {}) {
|
|
327
388
|
if (!runtimeErrorReportingEnabled(options)) return false;
|
|
328
|
-
if (readProductCredential(options).status !== 'ok') return false;
|
|
329
389
|
try {
|
|
330
390
|
const diagnostics = runtimeErrorsDiagnostics(options);
|
|
331
391
|
if (diagnostics.status !== 'ready' || diagnostics.pending_count === 0) return false;
|
|
332
|
-
|
|
392
|
+
if (throttleReason(options, readDelivery(options), diagnostics.high_watermark) !== null) return false;
|
|
393
|
+
return readProductCredential(options).status === 'ok';
|
|
333
394
|
} catch {
|
|
334
395
|
return false;
|
|
335
396
|
}
|
package/src/runtime-errors.mjs
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
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
|
*
|
|
@@ -15,8 +19,9 @@ import { dirname, join } from 'node:path';
|
|
|
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
|
|
19
|
-
*
|
|
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';
|
|
@@ -54,7 +59,9 @@ const plain = (value) => typeof value === 'object' && value !== null && !Array.i
|
|
|
54
59
|
const exact = (value, keys) => Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
|
|
55
60
|
const validTime = (value) => typeof value === 'string' && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
|
|
56
61
|
const validVersion = (value) => typeof value === 'string' && /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(value);
|
|
57
|
-
|
|
62
|
+
// 収集に対応するOS。storeを本人だけが触れる形で置けると確かめられるものだけを載せる。
|
|
63
|
+
const SUPPORTED_OS = Object.freeze(['darwin', 'linux', 'win32']);
|
|
64
|
+
const validOs = (value) => typeof value === 'string' && SUPPORTED_OS.includes(value);
|
|
58
65
|
const validArch = (value) => typeof value === 'string' && ['x64', 'arm64', 'arm'].includes(value);
|
|
59
66
|
|
|
60
67
|
export function defaultFactoryReporterConfigPath(env = process.env) {
|
|
@@ -62,9 +69,19 @@ export function defaultFactoryReporterConfigPath(env = process.env) {
|
|
|
62
69
|
return join(env.XDG_CONFIG_HOME || join(home, '.config'), 'dotagents', 'factory-reporter.json');
|
|
63
70
|
}
|
|
64
71
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return
|
|
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');
|
|
68
85
|
}
|
|
69
86
|
|
|
70
87
|
function canonicalReporting(value) {
|
|
@@ -77,19 +94,19 @@ function canonicalReporting(value) {
|
|
|
77
94
|
return !value.enabled || (value.endpoint !== undefined && value.credential_file !== undefined);
|
|
78
95
|
}
|
|
79
96
|
|
|
80
|
-
// このOS
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
const collectionSupported = (options = {}) => (options.platform ?? hostPlatform()) !== 'win32';
|
|
97
|
+
// このOSで収集に対応するか。対応しないOSでは、設定が有効でも記録は作らず、`disabled`でなく`unsupported`と
|
|
98
|
+
// 答える——設定は有効なのに製品が黙って無効と答えると、受け側は故障と区別できない。
|
|
99
|
+
const collectionSupported = (options = {}) => SUPPORTED_OS.includes(options.platform ?? hostPlatform());
|
|
84
100
|
const inactiveCollection = (options = {}) => (collectionSupported(options) ? 'disabled' : 'unsupported');
|
|
85
101
|
|
|
86
102
|
export function runtimeErrorCollectionSupported(options = {}) {
|
|
87
103
|
return collectionSupported(options);
|
|
88
104
|
}
|
|
89
105
|
|
|
90
|
-
export function runtimeErrorReportingConfigPath(env = process.env) {
|
|
91
|
-
|
|
92
|
-
|
|
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');
|
|
93
110
|
}
|
|
94
111
|
|
|
95
112
|
/**
|
|
@@ -175,18 +192,63 @@ function assertPosix(info, mode) {
|
|
|
175
192
|
if ((info.mode & 0o777) !== mode || (typeof process.getuid === 'function' && info.uid !== process.getuid())) throw Error('store_unsafe');
|
|
176
193
|
}
|
|
177
194
|
|
|
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
|
+
|
|
178
212
|
export function ensureSafeDir(dir) {
|
|
179
|
-
if (hostPlatform() === 'win32') throw Error('store_unsafe');
|
|
180
213
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
181
214
|
const stats = lstatSync(dir);
|
|
215
|
+
// Windowsのjunctionも`isSymbolicLink`で落ちる。
|
|
182
216
|
if (!stats.isDirectory() || stats.isSymbolicLink()) throw Error('store_unsafe');
|
|
183
|
-
|
|
217
|
+
if (windows()) ensureWindowsDirOwnerOnly(dir);
|
|
218
|
+
else assertPosix(stats, 0o700);
|
|
184
219
|
}
|
|
185
220
|
|
|
186
|
-
|
|
221
|
+
/**
|
|
222
|
+
* `scratchDir`はWindowsだけが使う: DACLの読み取りが出力fileを置くフォルダ。既定はそのfileのフォルダ。
|
|
223
|
+
* storeの外のfile(合鍵)を確かめる時は、絞ってあるstoreのフォルダを渡す。
|
|
224
|
+
*/
|
|
225
|
+
export function ensureSafeFile(path, scratchDir = dirname(path)) {
|
|
187
226
|
const stats = lstatSync(path);
|
|
188
227
|
if (!stats.isFile() || stats.isSymbolicLink()) throw Error('store_unsafe');
|
|
189
|
-
|
|
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
|
+
}
|
|
190
252
|
}
|
|
191
253
|
|
|
192
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']);
|
|
@@ -235,8 +297,9 @@ function writeStore(path, store) {
|
|
|
235
297
|
const temporary = join(dirname(path), `.runtime-errors-${process.pid}-${randomBytes(6).toString('hex')}`);
|
|
236
298
|
try {
|
|
237
299
|
writeFileSync(temporary, `${JSON.stringify(store)}\n`, { mode: 0o600, flag: 'wx' });
|
|
238
|
-
|
|
239
|
-
|
|
300
|
+
// Windowsの一時fileはフォルダの権限を引き継ぐ。置き換えた後の`ensureSafeFile`が確かめる。
|
|
301
|
+
if (!windows()) assertPosix(statSync(temporary), 0o600);
|
|
302
|
+
replaceStoreFile(temporary, path);
|
|
240
303
|
ensureSafeFile(path);
|
|
241
304
|
} finally {
|
|
242
305
|
rmSync(temporary, { force: true });
|
|
@@ -256,9 +319,15 @@ function lock(path, fn) {
|
|
|
256
319
|
writeFileSync(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' });
|
|
257
320
|
break;
|
|
258
321
|
} catch (error) {
|
|
259
|
-
|
|
322
|
+
// Windowsは、消している最中のlockと同じ名前の作成を`EEXIST`でなく`EPERM`で断る。
|
|
323
|
+
if (!plain(error) || !(error.code === 'EEXIST' || (windows() && ['EPERM', 'EACCES'].includes(error.code)))) throw error;
|
|
260
324
|
let age = 0;
|
|
261
|
-
try {
|
|
325
|
+
try {
|
|
326
|
+
age = Date.now() - lstatSync(lockPath).mtimeMs;
|
|
327
|
+
} catch {
|
|
328
|
+
if (Date.now() >= deadline) throw Error('store_locked');
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
262
331
|
// crash残置lockの恒久ロックを避ける唯一の明示救済。閾値未満は正当な並行writerとして待つ。
|
|
263
332
|
if (age > LOCK_STALE_MS) { try { unlinkSync(lockPath); } catch {} continue; }
|
|
264
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
|
+
}
|