throughline 0.6.2 → 0.6.3
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 +18 -4
- package/README.md +6 -3
- package/docs/04_public_release_plan.md +1 -1
- package/docs/13_native_factory_diagnostics_plan.md +4 -2
- package/docs/BUGHUB_RUNTIME_ERROR_STORE_PLAN.md +25 -4
- package/package.json +1 -1
- package/src/cli/factory-diagnostics.mjs +4 -2
- package/src/cli/factory-diagnostics.test.mjs +25 -0
- package/src/factory-diagnostics.mjs +0 -1
- package/src/factory-diagnostics.test.mjs +18 -0
- package/src/runtime-error-store.mjs +74 -32
- package/src/runtime-error-store.test.mjs +51 -3
- package/src/windows-acl-test-helper.mjs +29 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,7 +10,21 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- `throughline factory-diagnostics --json` now reports the Codex hook summary
|
|
16
|
+
as `ready` when all three canonical managed hooks are ready. The Codex-only
|
|
17
|
+
overall aggregate no longer treats the separately exposed, uninspected
|
|
18
|
+
Claude connector as a blocking `unverified` state. The Claude connector
|
|
19
|
+
remains explicitly `unverified`; diagnostic output remains read-only and
|
|
20
|
+
privacy-safe.
|
|
21
|
+
- Windows runtime-error mutations no longer repeat identical PowerShell ACL
|
|
22
|
+
verification inside one bounded observation. Existing lock/store files are
|
|
23
|
+
still verified before use, new temporary files receive an exact
|
|
24
|
+
current-SID-only ACL before atomic replacement, and the five-second hook
|
|
25
|
+
observer deadline is unchanged.
|
|
26
|
+
|
|
27
|
+
## [0.6.2] — 2026-07-13
|
|
14
28
|
|
|
15
29
|
### Added
|
|
16
30
|
|
|
@@ -22,9 +36,9 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
22
36
|
owner-private atomic store with count, first/last seen, resolve/reopen,
|
|
23
37
|
monotonic cursor/ack, retention that preserves unacknowledged records, and
|
|
24
38
|
bounded `throughline runtime-errors ... --json` snapshot/diagnostics APIs.
|
|
25
|
-
-
|
|
26
|
-
|
|
27
|
-
|
|
39
|
+
- Collection remains disabled by default and the local store sends no network
|
|
40
|
+
traffic. Public commit `e6ce6e3`, CI `29238704750`, npm `latest`, tag / GitHub
|
|
41
|
+
Release, and a registry-derived isolated install were verified.
|
|
28
42
|
- Raised the Node.js floor to 22.13, where `node:sqlite` is available without
|
|
29
43
|
an experimental command-line flag; CI now exercises that exact minimum.
|
|
30
44
|
|
package/README.md
CHANGED
|
@@ -716,10 +716,13 @@ entry to the `tasks` array yourself:
|
|
|
716
716
|
|
|
717
717
|
## Commands
|
|
718
718
|
|
|
719
|
-
**v0.6.
|
|
720
|
-
|
|
719
|
+
**v0.6.3 はリリース準備中です。** factory diagnostics は Codex の managed
|
|
720
|
+
`UserPromptSubmit` / `PostToolUse` / `Stop` hooks がすべて canonical `ready` の場合に
|
|
721
|
+
Codex readiness を `ready` と返す。Claude connector は未検査のまま明示的に
|
|
722
|
+
`unverified` とし、Codex-only overall を妨げない。local runtime error
|
|
723
|
+
aggregate は collection が既定OFFで、canonical dotagents config の
|
|
721
724
|
`collection.enabled: true` を明示した場合だけ収集します。いずれも network I/O は
|
|
722
|
-
行いません。npm
|
|
725
|
+
行いません。v0.6.3 の npm publish、tag、GitHub Release は未実施です。
|
|
723
726
|
|
|
724
727
|
| Command | What it does |
|
|
725
728
|
| ---------------------------------------------- | ------------------------------------------------------------ |
|
|
@@ -138,7 +138,7 @@ schema v4 で PostToolUse (`capture-tool`) は廃止、L2/L3 は Stop 内で一
|
|
|
138
138
|
| **npm 公開 (v0.4.1): typed `/clear` も baton を書く + 2 経路の優先順位入れ替え** | 2026-05-09 `/clear` を UserPromptSubmit hook で検出した時点で当該セッションの `session_id` を `handoff_batons` に書き込み、次 SessionStart が確定的にそのセッションを引き継ぐ。これで multi-window で「最新更新セッション ≠ /clear したセッション」になるシナリオで `findLatestClaudePredecessor` heuristic が誤った前任を選ぶ問題を解消。2 経路の優先順位を **baton path = primary、auto path = fallback** に変更 (auto path は VSCode 拡張メニュー由来など UserPromptSubmit に届かない経路のフォールバック)。`THROUGHLINE_DISABLE_AUTO_HANDOFF=1` は fallback path のみに作用するようになった (typed `/clear` / `/tl` は env と無関係に発火する)。あわせて `.vscode/tasks.json` を git 追跡から外し (gitignore)、`ensureMonitorTaskFile` が hook 発火ごとに絶対パスを書き換える挙動による別環境での dirty diff を解消。`src/prompt-submit.test.mjs` を新設し、`isClearCommand` / `isBatonCommand` 判定 14 件と subprocess+DB 実体テスト 3 件を追加。詳細は [CHANGELOG.md](../CHANGELOG.md) |
|
|
139
139
|
| **npm 公開 (v0.4.7): Codex monitor direct discovery + 80% auto-refresh** | 2026-05-09 Codex Stop hook auto-refresh の verified usage threshold を 90% から 80% に変更し、Codex native auto-compact より先に Throughline DB memory refresh を試行する。estimate usage / estimated context window では mutation しない。`throughline monitor` は `~/.throughline/state` に加えて `~/.codex/sessions/**/rollout-*.jsonl` を直接 discovery し、Throughline state が未生成の現在 Codex thread も表示する。既存 state がある場合は state の usage snapshot を保持しつつ discovered rollout path / mtime を合流する。Codex 表示 ID は `codex:01` ではなく raw thread id 先頭 8 桁 (`019e085c`) にした。Codex open turn の transient `output_tokens` は token count に overlay するが、モデル欄の `live+<tokens>` marker は表示しない |
|
|
140
140
|
| **npm 公開 (v0.6.1): Spotter auditor context projection** | `throughline auditor-context`を追加。exact session/projectと最新完了L2 pairのorigin/turn/hash freshnessを検査し、fresh時だけbounded user/assistant本文をversioned JSONで返す。DBはread-onlyで開き、作成・migration・WAL書き込みをしない。Spotter側はproject opt-inでのみ利用する。580 tests、CI 6/6、pack scan、registry global install / doctorを通過し、npm / tag / GitHub Releaseを2026-07-13に公開した |
|
|
141
|
-
| **v0.6.2
|
|
141
|
+
| **npm 公開 (v0.6.2): factory diagnostics + local runtime error store** | `throughline factory-diagnostics --json` と `throughline runtime-errors ... --json` を追加。collection は canonical dotagents config の `collection.enabled: true` が明示された時だけ有効で既定OFF、store は network I/O を行わない。公開commit `e6ce6e3`、CI `29238704750`、npm `latest`、tag / GitHub Release、registry由来隔離installと診断snapshotを2026-07-13に確認した |
|
|
142
142
|
| **未リリース: Codex current-session 75% trigger** | Codex 自動発火を token-monitor に依存させず、global install が Codex `UserPromptSubmit` / `PostToolUse` hooks も登録する。hook は当該 Codex session の rollout `token_count` を直接読み、verified 75% 以上なら同じ user turn または tool loop 継続前に `$throughline` workflow 実行指示を `additionalContext` で注入する。Stop hook の guarded auto-refresh は残す。`~/.codex/config.toml` は旧 `codex_hooks = true` に加えて現行 `hooks = true` も有効化する |
|
|
143
143
|
| **グローバル E2E 検証** | 2026-04-17 別ディレクトリから `throughline doctor` 全緑を確認 |
|
|
144
144
|
|
|
@@ -23,8 +23,10 @@ session の本文、秘密、絶対パス、例外詳細を出力しない。
|
|
|
23
23
|
検証する。handoff memoryは一致確認済みproject/threadだけを数える。
|
|
24
24
|
- restore capabilityとlive smoke evidenceを分離する。未実行smokeは`unverified`だが、
|
|
25
25
|
capability readinessを恒常的に失敗扱いにはしない。
|
|
26
|
-
- Codex hookはcanonical shape
|
|
27
|
-
|
|
26
|
+
- Codex hook は canonical shape を検査し、3 managed event がすべて `ready` なら hook
|
|
27
|
+
summary も `ready` とする。trust 実火はこの read-only projection の判定対象外であり、
|
|
28
|
+
Claude connector はこの単位では未検査として明示 `unverified` にする。ただし後者は
|
|
29
|
+
Codex-only overall を阻害しない。
|
|
28
30
|
|
|
29
31
|
## 実施 TODO
|
|
30
32
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# BugHub runtime error store plan
|
|
2
2
|
|
|
3
|
-
Status:
|
|
3
|
+
Status: v0.6.2 complete; v0.6.3 Windows bounded-observer hardening in progress
|
|
4
4
|
|
|
5
5
|
This plan is the implementation TODO for Throughline's product-owned, local
|
|
6
6
|
runtime error projection. It implements the cross-repository contract in
|
|
@@ -43,10 +43,31 @@ existing transcript, handoff, or SQLite memory contracts.
|
|
|
43
43
|
- [x] Run the complete test suite and update product documentation.
|
|
44
44
|
- [x] Commit and push this repository independently.
|
|
45
45
|
|
|
46
|
+
### v0.6.3 Windows bounded-observer hardening
|
|
47
|
+
|
|
48
|
+
- [x] Reproduce the public-CI failure where a cold Windows Node 24 observer
|
|
49
|
+
exceeded the five-second product deadline while starting several
|
|
50
|
+
PowerShell ACL apply/verify processes.
|
|
51
|
+
- [x] Characterize the current-SID-only directory, SQLite lock, existing
|
|
52
|
+
store, unique temporary file, atomic replacement, and five-second
|
|
53
|
+
no-fallback contract before changing the implementation.
|
|
54
|
+
- [x] Bound one mutation to the minimum distinct ACL transitions: apply plus
|
|
55
|
+
read-back for a private directory and each newly created file, and one
|
|
56
|
+
read-before-use verification for an existing lock/store. Carry a private
|
|
57
|
+
in-process directory capability so unchanged paths are not rechecked by
|
|
58
|
+
another PowerShell process in the same mutation.
|
|
59
|
+
- [x] Keep malformed/symlink/ACL-drift state fail-loud, leave no late child or
|
|
60
|
+
partial final store after failure, and do not extend the product deadline.
|
|
61
|
+
- [x] Keep the production 3-second ACL and 5-second observer bounds unchanged,
|
|
62
|
+
but serialize Windows CI test files so parallel PowerShell fixture load
|
|
63
|
+
cannot consume those bounds before the contract under test runs.
|
|
64
|
+
- [ ] Run focused Windows matrix tests, the complete suite, pack inspection,
|
|
65
|
+
registry-derived smoke, and the public Node 22/24 CI matrix before publish.
|
|
66
|
+
|
|
46
67
|
## Release wave
|
|
47
68
|
|
|
48
69
|
- [x] Bump the package and release-facing documentation to `0.6.2` without moving an existing tag.
|
|
49
70
|
- [x] Run the full test suite, pack inspection, secret/path scan, and temporary-prefix install smoke.
|
|
50
|
-
- [
|
|
51
|
-
- [
|
|
52
|
-
- [
|
|
71
|
+
- [x] Push the release commit and require the public CI gate to pass (`e6ce6e3`, CI `29238704750`).
|
|
72
|
+
- [x] After the owner H gate, publish `throughline@0.6.2`, create tag/release, and verify npm `latest` plus a registry-derived isolated install. Global installation is deferred to the dotagents Mac rollout wave.
|
|
73
|
+
- [x] Confirm `throughline factory-diagnostics --json` and the runtime-error snapshot from the published package, then record the public SHA and results in the changelog and canonical docs.
|
package/package.json
CHANGED
|
@@ -163,9 +163,11 @@ export function inspectFactoryHooks({ codexHome = defaultCodexHome(), readHooks
|
|
|
163
163
|
return {
|
|
164
164
|
status: values.includes('not_ready')
|
|
165
165
|
? 'not_ready'
|
|
166
|
-
: values.includes('
|
|
166
|
+
: values.includes('unverified')
|
|
167
167
|
? 'unverified'
|
|
168
|
-
: '
|
|
168
|
+
: values.includes('ready')
|
|
169
|
+
? 'ready'
|
|
170
|
+
: 'not_applicable',
|
|
169
171
|
reason: 'hooks_inspected',
|
|
170
172
|
events,
|
|
171
173
|
};
|
|
@@ -168,6 +168,31 @@ test('factory-diagnostics hook inspection rejects corrupt and false-ready shapes
|
|
|
168
168
|
assert.equal(malformed.status, 'not_ready');
|
|
169
169
|
});
|
|
170
170
|
|
|
171
|
+
test('factory-diagnostics hook inspection summarizes every canonical ready event as ready', () => {
|
|
172
|
+
const result = inspectFactoryHooks({
|
|
173
|
+
readHooks: () => ({
|
|
174
|
+
configExists: true,
|
|
175
|
+
configReadable: true,
|
|
176
|
+
hooksExists: true,
|
|
177
|
+
hooksReadable: true,
|
|
178
|
+
featureEnabled: true,
|
|
179
|
+
expectedPromptCommand: 'prompt',
|
|
180
|
+
expectedPostToolUseCommand: 'post',
|
|
181
|
+
expectedStopCommand: 'stop',
|
|
182
|
+
managedPromptHooks: [{ type: 'command', command: 'prompt', timeoutSec: 30, async: false }],
|
|
183
|
+
legacyManagedPromptHooks: [],
|
|
184
|
+
managedPostToolUseHooks: [{ type: 'command', command: 'post', timeoutSec: 30, async: false }],
|
|
185
|
+
legacyManagedPostToolUseHooks: [],
|
|
186
|
+
managedStopHooks: [{ type: 'command', command: 'stop', timeoutSec: 300, async: false }],
|
|
187
|
+
legacyManagedStopHooks: [],
|
|
188
|
+
}),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
assert.deepEqual(result.events, { userPromptSubmit: 'ready', postToolUse: 'ready', stop: 'ready' });
|
|
192
|
+
assert.equal(result.status, 'ready');
|
|
193
|
+
assert.equal(result.reason, 'hooks_inspected');
|
|
194
|
+
});
|
|
195
|
+
|
|
171
196
|
function createFactorySchema(db) {
|
|
172
197
|
db.exec(`
|
|
173
198
|
PRAGMA user_version = 8;
|
|
@@ -45,6 +45,24 @@ test('factory diagnostics: restore は実行していない smoke を ready に
|
|
|
45
45
|
assert.equal(result.overall.status, 'ready');
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
test('factory diagnostics: Claude connector 未検査は Codex-only ready snapshot を unverified にしない', () => {
|
|
49
|
+
const result = buildFactoryDiagnostics({
|
|
50
|
+
version: '0.6.2',
|
|
51
|
+
database: { status: 'ready', schemaVersion: 8, supportedSchemaVersion: 8, handoffMemory: true },
|
|
52
|
+
hooks: {
|
|
53
|
+
status: 'ready',
|
|
54
|
+
events: { userPromptSubmit: 'ready', postToolUse: 'ready', stop: 'ready' },
|
|
55
|
+
},
|
|
56
|
+
thread: { status: 'ready', rolloutAvailable: true },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
assert.equal(result.hooks.status, 'ready');
|
|
60
|
+
assert.equal(result.connectors.codex.status, 'ready');
|
|
61
|
+
assert.equal(result.connectors.claude.status, 'unverified');
|
|
62
|
+
assert.equal(result.connectors.claude.reason, 'diagnostic_unverified');
|
|
63
|
+
assert.equal(result.overall.status, 'ready');
|
|
64
|
+
});
|
|
65
|
+
|
|
48
66
|
test('factory diagnostics: JSON に本文、秘密、絶対 path、生 state を含めない', () => {
|
|
49
67
|
const secret = 'sk-test-very-secret';
|
|
50
68
|
const body = 'ユーザーの prompt 本文';
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
rmSync,
|
|
10
10
|
writeFileSync,
|
|
11
11
|
} from 'node:fs';
|
|
12
|
-
import
|
|
12
|
+
import childProcess from 'node:child_process';
|
|
13
13
|
import { arch as hostArch, homedir, platform as hostPlatform } from 'node:os';
|
|
14
14
|
import { dirname, join } from 'node:path';
|
|
15
15
|
import { createRequire } from 'node:module';
|
|
@@ -27,6 +27,7 @@ const BEST_EFFORT_TIMEOUT_MS = 750;
|
|
|
27
27
|
const WINDOWS_BEST_EFFORT_TIMEOUT_MS = 5_000;
|
|
28
28
|
const WINDOWS_ACL_TIMEOUT_MS = 3_000;
|
|
29
29
|
const RESOLUTION_REASONS = new Set(['manual', 'recovered']);
|
|
30
|
+
const PRIVATE_DIRECTORY_CAPABILITY = Symbol('throughline.private-directory');
|
|
30
31
|
|
|
31
32
|
const DEFINITIONS = Object.freeze({
|
|
32
33
|
HOOK_SESSION_START_FAILED: Object.freeze({
|
|
@@ -86,13 +87,13 @@ export function observeRuntimeError(input, options = {}) {
|
|
|
86
87
|
if (!definition) throw new TypeError('未登録の runtime error code です');
|
|
87
88
|
if (!collectionEnabled(options)) return { status: 'disabled' };
|
|
88
89
|
|
|
89
|
-
return withStoreLock(options, () => {
|
|
90
|
+
return withStoreLock(options, (privateDirectory) => {
|
|
90
91
|
const now = normalizeTimestamp(input.now);
|
|
91
92
|
const version = normalizeVersion(options.version ?? PACKAGE_VERSION);
|
|
92
93
|
const fingerprint = createHash('sha256')
|
|
93
94
|
.update(['throughline', definition.component, input.code, definition.template].join('\0'))
|
|
94
95
|
.digest('hex');
|
|
95
|
-
const store = readStore(options);
|
|
96
|
+
const store = readStore(options, { privateDirectory });
|
|
96
97
|
const existing = store.records.find((record) => record.fingerprint === fingerprint);
|
|
97
98
|
const sequence = nextSequence(store);
|
|
98
99
|
if (existing) {
|
|
@@ -115,7 +116,7 @@ export function observeRuntimeError(input, options = {}) {
|
|
|
115
116
|
status: 'open', resolved_at: null, reason_code: null, sequence,
|
|
116
117
|
});
|
|
117
118
|
}
|
|
118
|
-
writeStore(store, options);
|
|
119
|
+
writeStore(store, options, privateDirectory);
|
|
119
120
|
return { status: 'recorded', fingerprint, sequence };
|
|
120
121
|
});
|
|
121
122
|
}
|
|
@@ -124,8 +125,8 @@ export function resolveRuntimeError(fingerprint, options = {}) {
|
|
|
124
125
|
assertExactOptions(options, ['env', 'configPath', 'storePath', 'now', 'reasonCode']);
|
|
125
126
|
assertFingerprint(fingerprint);
|
|
126
127
|
if (!collectionEnabled(options)) return { status: 'disabled' };
|
|
127
|
-
return withStoreLock(options, () => {
|
|
128
|
-
const store = readStore(options);
|
|
128
|
+
return withStoreLock(options, (privateDirectory) => {
|
|
129
|
+
const store = readStore(options, { privateDirectory });
|
|
129
130
|
const record = store.records.find((candidate) => candidate.fingerprint === fingerprint);
|
|
130
131
|
if (!record) return { status: 'not_found' };
|
|
131
132
|
if (record.status === 'resolved') return { status: 'resolved', sequence: record.sequence };
|
|
@@ -135,7 +136,7 @@ export function resolveRuntimeError(fingerprint, options = {}) {
|
|
|
135
136
|
record.resolved_at = normalizeTimestamp(options.now);
|
|
136
137
|
record.reason_code = reasonCode;
|
|
137
138
|
record.sequence = nextSequence(store);
|
|
138
|
-
writeStore(store, options);
|
|
139
|
+
writeStore(store, options, privateDirectory);
|
|
139
140
|
return { status: 'resolved', sequence: record.sequence };
|
|
140
141
|
});
|
|
141
142
|
}
|
|
@@ -144,8 +145,8 @@ export function reopenRuntimeError(fingerprint, options = {}) {
|
|
|
144
145
|
assertExactOptions(options, ['env', 'configPath', 'storePath']);
|
|
145
146
|
assertFingerprint(fingerprint);
|
|
146
147
|
if (!collectionEnabled(options)) return { status: 'disabled' };
|
|
147
|
-
return withStoreLock(options, () => {
|
|
148
|
-
const store = readStore(options);
|
|
148
|
+
return withStoreLock(options, (privateDirectory) => {
|
|
149
|
+
const store = readStore(options, { privateDirectory });
|
|
149
150
|
const record = store.records.find((candidate) => candidate.fingerprint === fingerprint);
|
|
150
151
|
if (!record) return { status: 'not_found' };
|
|
151
152
|
if (record.status === 'open') return { status: 'open', sequence: record.sequence };
|
|
@@ -153,7 +154,7 @@ export function reopenRuntimeError(fingerprint, options = {}) {
|
|
|
153
154
|
record.resolved_at = null;
|
|
154
155
|
record.reason_code = null;
|
|
155
156
|
record.sequence = nextSequence(store);
|
|
156
|
-
writeStore(store, options);
|
|
157
|
+
writeStore(store, options, privateDirectory);
|
|
157
158
|
return { status: 'open', sequence: record.sequence };
|
|
158
159
|
});
|
|
159
160
|
}
|
|
@@ -161,12 +162,12 @@ export function reopenRuntimeError(fingerprint, options = {}) {
|
|
|
161
162
|
export function acknowledgeRuntimeErrors(cursor, options = {}) {
|
|
162
163
|
if (!Number.isSafeInteger(cursor) || cursor < 0) throw new TypeError('cursor は非負の整数が必要です');
|
|
163
164
|
if (!collectionEnabled(options)) return { status: 'disabled', acknowledgedThrough: 0 };
|
|
164
|
-
return withStoreLock(options, () => {
|
|
165
|
-
const store = readStore(options);
|
|
165
|
+
return withStoreLock(options, (privateDirectory) => {
|
|
166
|
+
const store = readStore(options, { privateDirectory });
|
|
166
167
|
const highWatermark = store.next_sequence - 1;
|
|
167
168
|
if (cursor > highWatermark) throw new RangeError('cursor がstore high watermarkを超えています');
|
|
168
169
|
store.acknowledged_through = Math.max(store.acknowledged_through, cursor);
|
|
169
|
-
writeStore(store, options);
|
|
170
|
+
writeStore(store, options, privateDirectory);
|
|
170
171
|
return { status: 'acknowledged', acknowledgedThrough: store.acknowledged_through };
|
|
171
172
|
});
|
|
172
173
|
}
|
|
@@ -183,8 +184,8 @@ export function compactRuntimeErrors({
|
|
|
183
184
|
}
|
|
184
185
|
const options = { env, configPath, storePath };
|
|
185
186
|
if (!collectionEnabled(options)) return { status: 'disabled', removed: 0 };
|
|
186
|
-
return withStoreLock(options, () => {
|
|
187
|
-
const store = readStore(options);
|
|
187
|
+
return withStoreLock(options, (privateDirectory) => {
|
|
188
|
+
const store = readStore(options, { privateDirectory });
|
|
188
189
|
const cutoff = Date.parse(normalizeTimestamp(now)) - retentionMs;
|
|
189
190
|
const before = store.records.length;
|
|
190
191
|
store.records = store.records.filter((record) => {
|
|
@@ -192,7 +193,7 @@ export function compactRuntimeErrors({
|
|
|
192
193
|
const expired = Date.parse(record.last_seen) <= cutoff;
|
|
193
194
|
return !(acknowledged && record.status === 'resolved' && expired);
|
|
194
195
|
});
|
|
195
|
-
writeStore(store, options);
|
|
196
|
+
writeStore(store, options, privateDirectory);
|
|
196
197
|
return { status: 'compacted', removed: before - store.records.length };
|
|
197
198
|
});
|
|
198
199
|
}
|
|
@@ -256,7 +257,7 @@ export function getRuntimeErrorDiagnostics(options = {}) {
|
|
|
256
257
|
export function recordRuntimeErrorBestEffort(code, options = {}) {
|
|
257
258
|
const { stderr = process.stderr, ...storeOptions } = options;
|
|
258
259
|
try {
|
|
259
|
-
const child = spawnSync(process.execPath, [fileURLToPath(new URL('./runtime-error-observer.mjs', import.meta.url)), code], {
|
|
260
|
+
const child = childProcess.spawnSync(process.execPath, [fileURLToPath(new URL('./runtime-error-observer.mjs', import.meta.url)), code], {
|
|
260
261
|
env: storeOptions.env ?? process.env,
|
|
261
262
|
encoding: 'utf8',
|
|
262
263
|
stdio: 'ignore',
|
|
@@ -288,13 +289,13 @@ function emptyStore() {
|
|
|
288
289
|
};
|
|
289
290
|
}
|
|
290
291
|
|
|
291
|
-
function readStore(options, { missingIsEmpty = true } = {}) {
|
|
292
|
+
function readStore(options, { missingIsEmpty = true, privateDirectory } = {}) {
|
|
292
293
|
const path = options.storePath || defaultRuntimeErrorStorePath(options.env);
|
|
293
294
|
let parsed;
|
|
294
295
|
try {
|
|
295
296
|
const info = lstatSync(path);
|
|
296
297
|
if (!info.isFile() || info.isSymbolicLink()) throw new Error('runtime error store path unsafe');
|
|
297
|
-
|
|
298
|
+
assertPrivateDirectoryCapability(privateDirectory, dirname(path), options.env);
|
|
298
299
|
assertPrivateStoreFile(info, options.env, path);
|
|
299
300
|
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
300
301
|
} catch (error) {
|
|
@@ -305,19 +306,28 @@ function readStore(options, { missingIsEmpty = true } = {}) {
|
|
|
305
306
|
return parsed;
|
|
306
307
|
}
|
|
307
308
|
|
|
308
|
-
function writeStore(store, options) {
|
|
309
|
+
function writeStore(store, options, privateDirectory) {
|
|
309
310
|
validateStore(store);
|
|
310
311
|
const path = options.storePath || defaultRuntimeErrorStorePath(options.env);
|
|
311
312
|
const directory = dirname(path);
|
|
312
|
-
|
|
313
|
+
assertPrivateDirectoryCapability(privateDirectory, directory, options.env);
|
|
313
314
|
const temporary = join(directory, `.runtime-errors.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
|
|
314
315
|
try {
|
|
315
316
|
writeFileSync(temporary, `${JSON.stringify(store)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
316
|
-
if (!isWindows(options.env))
|
|
317
|
+
if (!isWindows(options.env)) {
|
|
318
|
+
chmodSync(temporary, 0o600);
|
|
319
|
+
} else {
|
|
320
|
+
// The apply script performs an exact read-back verification. Repeating
|
|
321
|
+
// the same PowerShell verification here only consumes the bounded hook
|
|
322
|
+
// observer deadline without protecting a new state transition.
|
|
323
|
+
applyAndVerifyWindowsAcl(temporary, false);
|
|
324
|
+
assertPrivateStoreFileShape(lstatSync(temporary));
|
|
325
|
+
}
|
|
326
|
+
// ACL/mode is complete before replacement, so an ACL failure leaves the
|
|
327
|
+
// previous final store intact. Rename preserves the prepared file ACL.
|
|
317
328
|
renameSync(temporary, path);
|
|
318
|
-
if (!isWindows(options.env))
|
|
319
|
-
else
|
|
320
|
-
assertPrivateStoreFile(lstatSync(path), options.env, path);
|
|
329
|
+
if (!isWindows(options.env)) assertPrivateStoreFile(lstatSync(path), options.env, path);
|
|
330
|
+
else assertPrivateStoreFileShape(lstatSync(path));
|
|
321
331
|
} finally {
|
|
322
332
|
rmSync(temporary, { force: true });
|
|
323
333
|
}
|
|
@@ -339,15 +349,15 @@ function withStoreLock(options, operation) {
|
|
|
339
349
|
if (isWindows(options.env)) applyAndVerifyWindowsAcl(lockPath, false);
|
|
340
350
|
else chmodSync(lockPath, 0o600);
|
|
341
351
|
}
|
|
342
|
-
|
|
352
|
+
if (created && isWindows(options.env)) assertPrivateStoreFileShape(lstatSync(lockPath));
|
|
353
|
+
else assertPrivateStoreFile(lstatSync(lockPath), options.env, lockPath);
|
|
343
354
|
const database = new DatabaseSync(lockPath);
|
|
344
355
|
let active = false;
|
|
345
356
|
try {
|
|
346
|
-
assertPrivateStoreFile(lstatSync(lockPath), options.env, lockPath);
|
|
347
357
|
database.exec('PRAGMA busy_timeout=5000; PRAGMA synchronous=FULL');
|
|
348
358
|
database.exec('BEGIN IMMEDIATE');
|
|
349
359
|
active = true;
|
|
350
|
-
const result = operation();
|
|
360
|
+
const result = operation(privateDirectoryCapability(directory, options.env));
|
|
351
361
|
database.exec('COMMIT');
|
|
352
362
|
active = false;
|
|
353
363
|
return result;
|
|
@@ -363,10 +373,42 @@ function ensurePrivateStoreDirectory(directory, env = process.env) {
|
|
|
363
373
|
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('runtime error store directory unsafe');
|
|
364
374
|
if (isWindows(env)) {
|
|
365
375
|
applyAndVerifyWindowsAcl(directory, true);
|
|
376
|
+
assertPrivateStoreDirectoryShape(lstatSync(directory));
|
|
366
377
|
} else {
|
|
367
378
|
chmodSync(directory, 0o700);
|
|
379
|
+
assertPrivateStoreDirectory(directory, env);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function privateDirectoryCapability(directory, env) {
|
|
384
|
+
return Object.freeze({
|
|
385
|
+
directory,
|
|
386
|
+
windows: isWindows(env),
|
|
387
|
+
[PRIVATE_DIRECTORY_CAPABILITY]: true,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function assertPrivateDirectoryCapability(capability, directory, env) {
|
|
392
|
+
if (!capability || capability[PRIVATE_DIRECTORY_CAPABILITY] !== true ||
|
|
393
|
+
capability.directory !== directory || capability.windows !== isWindows(env)) {
|
|
394
|
+
// Read-only callers do not hold a mutation capability and still perform
|
|
395
|
+
// the complete ACL/mode verification immediately before reading.
|
|
396
|
+
assertPrivateStoreDirectory(directory, env);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
assertPrivateStoreDirectoryShape(lstatSync(directory));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function assertPrivateStoreDirectoryShape(info) {
|
|
403
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
404
|
+
throw new Error('runtime error store directory unsafe');
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function assertPrivateStoreFileShape(info) {
|
|
409
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
410
|
+
throw new Error('runtime error store path unsafe');
|
|
368
411
|
}
|
|
369
|
-
assertPrivateStoreDirectory(directory, env);
|
|
370
412
|
}
|
|
371
413
|
|
|
372
414
|
function isWindows(env = process.env) {
|
|
@@ -496,13 +538,13 @@ function isCanonicalTimestamp(value) {
|
|
|
496
538
|
|
|
497
539
|
function assertPrivateStoreDirectory(directory, env = process.env) {
|
|
498
540
|
const info = lstatSync(directory);
|
|
499
|
-
|
|
541
|
+
assertPrivateStoreDirectoryShape(info);
|
|
500
542
|
if (isWindows(env)) verifyWindowsAcl(directory, true);
|
|
501
543
|
else assertPosixOwnerMode(info, 0o700);
|
|
502
544
|
}
|
|
503
545
|
|
|
504
546
|
function assertPrivateStoreFile(info, env = process.env, path) {
|
|
505
|
-
|
|
547
|
+
assertPrivateStoreFileShape(info);
|
|
506
548
|
if (isWindows(env)) verifyWindowsAcl(path, false);
|
|
507
549
|
else assertPosixOwnerMode(info, 0o600);
|
|
508
550
|
}
|
|
@@ -521,7 +563,7 @@ function verifyWindowsAcl(path, directory) {
|
|
|
521
563
|
}
|
|
522
564
|
|
|
523
565
|
function runWindowsAclScript(path, directory, script) {
|
|
524
|
-
const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
566
|
+
const result = childProcess.spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
525
567
|
env: { ...process.env, FACTORY_ACL_PATH: path, FACTORY_ACL_DIRECTORY: directory ? '1' : '0' },
|
|
526
568
|
stdio: 'ignore', timeout: WINDOWS_ACL_TIMEOUT_MS, windowsHide: true,
|
|
527
569
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { spawn } from 'node:child_process';
|
|
4
|
-
import { chmodSync, mkdtempSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import childProcess, { spawn } from 'node:child_process';
|
|
4
|
+
import { chmodSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { dirname, join } from 'node:path';
|
|
7
7
|
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
reopenRuntimeError,
|
|
17
17
|
resolveRuntimeError,
|
|
18
18
|
} from './runtime-error-store.mjs';
|
|
19
|
-
import { applyWindowsPrivateAcl } from './windows-acl-test-helper.mjs';
|
|
19
|
+
import { applyWindowsPrivateAcl, verifyWindowsPrivateAcl } from './windows-acl-test-helper.mjs';
|
|
20
20
|
|
|
21
21
|
const TEST_PLATFORM = process.platform === 'win32' ? 'win32' : 'darwin';
|
|
22
22
|
|
|
@@ -83,6 +83,50 @@ test('runtime error store: Windows native uses the canonical LocalAppData paths'
|
|
|
83
83
|
);
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
+
test('runtime error store: one Windows mutation spends ACL processes only on distinct state transitions', (t) => {
|
|
87
|
+
const box = sandbox();
|
|
88
|
+
box.env.OS = 'Windows_NT';
|
|
89
|
+
enableCollection(box);
|
|
90
|
+
const calls = [];
|
|
91
|
+
t.mock.method(childProcess, 'spawnSync', (command, args, options) => {
|
|
92
|
+
calls.push({ command, args, options });
|
|
93
|
+
return { status: 0, signal: null, error: undefined };
|
|
94
|
+
});
|
|
95
|
+
const options = { env: box.env, configPath: box.configPath, storePath: box.storePath };
|
|
96
|
+
|
|
97
|
+
assert.equal(observeRuntimeError({ code: 'HOOK_CODEX_FAILED' }, options).status, 'recorded');
|
|
98
|
+
assert.equal(calls.length, 3, 'new directory, lock, and store each require one apply+verify process');
|
|
99
|
+
assert.ok(calls.every((call) => call.command === 'powershell.exe'));
|
|
100
|
+
calls.length = 0;
|
|
101
|
+
|
|
102
|
+
assert.equal(observeRuntimeError({ code: 'HOOK_CODEX_FAILED' }, options).status, 'recorded');
|
|
103
|
+
assert.equal(calls.length, 4, 'directory apply, existing lock/store verify, and replacement store apply are distinct');
|
|
104
|
+
assert.ok(calls.every((call) => call.options.timeout === 3_000));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('runtime error store: Windows temporary ACL failure leaves the previous atomic store intact', (t) => {
|
|
108
|
+
const box = sandbox();
|
|
109
|
+
box.env.OS = 'Windows_NT';
|
|
110
|
+
enableCollection(box);
|
|
111
|
+
let calls = 0;
|
|
112
|
+
let failAt = Number.POSITIVE_INFINITY;
|
|
113
|
+
t.mock.method(childProcess, 'spawnSync', () => {
|
|
114
|
+
calls += 1;
|
|
115
|
+
return { status: calls === failAt ? 1 : 0, signal: null, error: undefined };
|
|
116
|
+
});
|
|
117
|
+
const options = { env: box.env, configPath: box.configPath, storePath: box.storePath };
|
|
118
|
+
|
|
119
|
+
observeRuntimeError({ code: 'HOOK_CODEX_FAILED', now: '2026-07-13T00:00:00.000Z' }, options);
|
|
120
|
+
const before = readFileSync(box.storePath, 'utf8');
|
|
121
|
+
failAt = calls + 4;
|
|
122
|
+
assert.throws(
|
|
123
|
+
() => observeRuntimeError({ code: 'HOOK_CODEX_FAILED', now: '2026-07-13T00:01:00.000Z' }, options),
|
|
124
|
+
/Windows owner-only ACL verification failed/,
|
|
125
|
+
);
|
|
126
|
+
assert.equal(readFileSync(box.storePath, 'utf8'), before);
|
|
127
|
+
assert.deepEqual(readdirSync(dirname(box.storePath)).filter((name) => name.endsWith('.tmp')), []);
|
|
128
|
+
});
|
|
129
|
+
|
|
86
130
|
test('runtime error store: reporting config and credentials are ignored and no network API is accepted', () => {
|
|
87
131
|
const box = sandbox();
|
|
88
132
|
enableCollection(box, {
|
|
@@ -235,6 +279,10 @@ test('runtime error store: atomic private store has owner-only modes and bounded
|
|
|
235
279
|
if (process.platform !== 'win32') {
|
|
236
280
|
assert.equal(statSync(dirname(box.storePath)).mode & 0o777, 0o700);
|
|
237
281
|
assert.equal(statSync(box.storePath).mode & 0o777, 0o600);
|
|
282
|
+
} else {
|
|
283
|
+
verifyWindowsPrivateAcl(dirname(box.storePath), true);
|
|
284
|
+
verifyWindowsPrivateAcl(`${box.storePath}.lock.sqlite`);
|
|
285
|
+
verifyWindowsPrivateAcl(box.storePath);
|
|
238
286
|
}
|
|
239
287
|
assert.doesNotThrow(() => JSON.parse(readFileSync(box.storePath, 'utf8')));
|
|
240
288
|
|
|
@@ -27,3 +27,32 @@ if($isDir){[System.IO.Directory]::SetAccessControl($target,$acl)}else{[System.IO
|
|
|
27
27
|
});
|
|
28
28
|
if (result.status !== 0) throw new Error(result.stderr || 'Windows ACL fixture setup failed');
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
export function verifyWindowsPrivateAcl(path, directory = false) {
|
|
32
|
+
if (process.platform !== 'win32') return;
|
|
33
|
+
const script = String.raw`
|
|
34
|
+
$ErrorActionPreference='Stop'
|
|
35
|
+
$target=$env:THROUGHLINE_TEST_ACL_PATH; $isDir=$env:THROUGHLINE_TEST_ACL_DIRECTORY -eq '1'
|
|
36
|
+
$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
|
37
|
+
$acl=if($isDir){[System.IO.Directory]::GetAccessControl($target)}else{[System.IO.File]::GetAccessControl($target)}
|
|
38
|
+
$owner=$acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value
|
|
39
|
+
if($owner -ne $sid){exit 41}
|
|
40
|
+
$rules=@($acl.GetAccessRules($true,$true,[System.Security.Principal.SecurityIdentifier]))
|
|
41
|
+
if($rules.Count -ne 1){exit 42}
|
|
42
|
+
$rule=$rules[0]
|
|
43
|
+
if($rule.IdentityReference.Value -ne $sid -or $rule.AccessControlType -ne 'Allow' -or $rule.IsInherited -or ($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne [System.Security.AccessControl.FileSystemRights]::FullControl){exit 43}
|
|
44
|
+
`;
|
|
45
|
+
const result = spawnSync('powershell.exe', [
|
|
46
|
+
'-NoProfile', '-NonInteractive', '-Command', script,
|
|
47
|
+
], {
|
|
48
|
+
encoding: 'utf8',
|
|
49
|
+
timeout: 3_000,
|
|
50
|
+
windowsHide: true,
|
|
51
|
+
env: {
|
|
52
|
+
...process.env,
|
|
53
|
+
THROUGHLINE_TEST_ACL_PATH: path,
|
|
54
|
+
THROUGHLINE_TEST_ACL_DIRECTORY: directory ? '1' : '0',
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
if (result.status !== 0) throw new Error(result.stderr || 'Windows ACL fixture verification failed');
|
|
58
|
+
}
|