throughline 0.14.2 → 0.14.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 CHANGED
@@ -10,6 +10,20 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.14.3] — 2026-10-05
14
+
15
+ ### 修正
16
+
17
+ - Windowsで、同じhookが2本同時に走った時に、片方が`Windows owner-only ACL verification failed`で失敗することがあった不具合を直す。Cursorは同じStop hookを2本ほぼ同時に起動する。新しいフォルダでの最初の応答では、1本目が受領記録のlockファイルを作り、その後で所有者だけの権限(ACL)を付けていた。権限を付け終わるまでの約1秒、lockファイルはフォルダから継承した権限のまま見える。2本目がその間に権限を検証して落ち、`HOOK_PROCESS_TURN_FAILED`に数えられていた。会話の保存は同じStopで済んでいて、失われていない。
18
+ - lockファイルは、別名で作って権限を付け、検証してから、hard linkで最終の場所へ出す。最終の場所に現れた時点で、所有者だけの権限が付いている。先に出した側が勝ち、後から来た側は既にあるlockを検証して使う。
19
+ - 実行時エラーの記録(`runtime-errors`)のlockファイルも同じ作りだったので、同じ形に直す。
20
+ - macOSとLinuxの動作は変わらない(作る時点で`0600`を指定しているので、この隙間は無い)。既にあるlockファイルの検証は変えていない。
21
+
22
+ ### 確認した範囲
23
+
24
+ - Windows 11(10.0.26200)の実機。0.14.2では、lockファイルが作られて権限がまだ付いていない状態で2本目を走らせると、Cursorの実際の失敗と同じ文面・同じ呼び出し位置で落ちる。0.14.3は、同じ場面(1本目が別名を作った直後)で2本とも成功し、lockファイルと受領記録の権限は所有者だけになる。
25
+ - 同じ実機で、新しい置き場に2本・4本を同時に起動する確認を、受領記録で51回、実行時エラーの記録で15回行い、失敗0。0.14.2は同じ確認18回のうち1回で失敗した。
26
+
13
27
  ## [0.14.2] — 2026-10-05
14
28
 
15
29
  ### 修正
@@ -1710,7 +1724,8 @@ two attempts, instrument first instead of patching again.
1710
1724
 
1711
1725
  ---
1712
1726
 
1713
- [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.14.2...HEAD
1727
+ [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.14.3...HEAD
1728
+ [0.14.3]: https://github.com/kitepon/Throughline/compare/v0.14.2...v0.14.3
1714
1729
  [0.14.2]: https://github.com/kitepon/Throughline/compare/v0.14.1...v0.14.2
1715
1730
  [0.14.1]: https://github.com/kitepon/Throughline/compare/v0.14.0...v0.14.1
1716
1731
  [0.14.0]: https://github.com/kitepon/Throughline/compare/v0.13.0...v0.14.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.14.2",
3
+ "version": "0.14.3",
4
4
  "type": "module",
5
5
  "description": "Persistent memory hooks for Claude Code, Codex, Grok, and Cursor (/clear-safe context compression)",
6
6
  "keywords": [
@@ -2,6 +2,7 @@ import { createHash, randomBytes } from 'node:crypto';
2
2
  import { CLAUDE_HOST } from './hosts/identity.mjs';
3
3
  import {
4
4
  chmodSync,
5
+ existsSync,
5
6
  lstatSync,
6
7
  mkdirSync,
7
8
  readFileSync,
@@ -13,7 +14,7 @@ import { dirname, join } from 'node:path';
13
14
  import { DatabaseSync } from 'node:sqlite';
14
15
  import { hashAuditorBody } from './body-digest.mjs';
15
16
  import { normalizeProjectPathForCompare } from './project-path.mjs';
16
- import { applyAndVerifyWindowsAcl, isWindows, verifyWindowsAcl } from './os/windows-acl.mjs';
17
+ import { applyAndVerifyWindowsAcl, createWindowsPrivateFile, isWindows, verifyWindowsAcl } from './os/windows-acl.mjs';
17
18
  import { windowsLocalAppData, xdgStateHome } from './os/app-dirs.mjs';
18
19
 
19
20
  export const COMPLETED_TURN_RECEIPT_STORE_SCHEMA = 'throughline.completed_turn_receipts.v1';
@@ -157,15 +158,16 @@ function withStoreLock(normalized, options, operation) {
157
158
  ensurePrivateDirectory(directory, options.env);
158
159
  const lockPath = `${storePath}.lock.sqlite`;
159
160
  let created = false;
160
- try {
161
- writeFileSync(lockPath, '', { encoding: 'utf8', mode: 0o600, flag: 'wx' });
162
- created = true;
163
- } catch (error) {
164
- if (error?.code !== 'EEXIST') throw error;
165
- }
166
- if (created) {
167
- if (isWindows(options.env)) applyAndVerifyWindowsAcl(lockPath, false);
168
- else chmodSync(lockPath, 0o600);
161
+ if (isWindows(options.env)) {
162
+ created = !existsSync(lockPath) && createWindowsPrivateFile(lockPath);
163
+ } else {
164
+ try {
165
+ writeFileSync(lockPath, '', { encoding: 'utf8', mode: 0o600, flag: 'wx' });
166
+ created = true;
167
+ } catch (error) {
168
+ if (error?.code !== 'EEXIST') throw error;
169
+ }
170
+ if (created) chmodSync(lockPath, 0o600);
169
171
  }
170
172
  if (created && isWindows(options.env)) assertPrivateFileShape(lstatSync(lockPath));
171
173
  else assertPrivateFile(lstatSync(lockPath), options.env, lockPath);
@@ -1,7 +1,7 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import childProcess from 'node:child_process';
4
- import { mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
4
+ import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { dirname, join } from 'node:path';
7
7
  import {
@@ -159,6 +159,29 @@ test('completed turn receipt: one Windows mutation spends ACL processes only on
159
159
  }
160
160
  });
161
161
 
162
+ test('completed turn receipt: Windows lock file reaches its final path only after its ACL is applied', (t) => {
163
+ const box = sandbox();
164
+ const env = { OS: 'Windows_NT', HOME: box.root, USERPROFILE: box.root };
165
+ const lockPath = `${box.storePath}.lock.sqlite`;
166
+ const applied = new Set();
167
+ const exposed = [];
168
+ // 同時に走る別のhookは、lockが見えた時点でそのACLを検証する。ACL processの起動を、その検証が入りうる瞬間として見る。
169
+ t.mock.method(childProcess, 'spawnSync', (command, args, options) => {
170
+ if (existsSync(lockPath) && !applied.has(statSync(lockPath, { bigint: true }).ino)) exposed.push(options.env.THROUGHLINE_ACL_PATH);
171
+ if (args.at(-1).includes('Set-Acl')) applied.add(statSync(options.env.THROUGHLINE_ACL_PATH, { bigint: true }).ino);
172
+ return { status: 0, signal: null, error: undefined };
173
+ });
174
+
175
+ try {
176
+ writeCompletedTurnReceipt(input(1), { env, storePath: box.storePath });
177
+ assert.deepEqual(exposed, [], 'the lock must not be visible with an inherited ACL');
178
+ assert.ok(applied.has(statSync(lockPath, { bigint: true }).ino));
179
+ assert.deepEqual(readdirSync(dirname(lockPath)).filter((name) => name.endsWith('.tmp')), []);
180
+ } finally {
181
+ rmSync(box.root, { recursive: true, force: true });
182
+ }
183
+ });
184
+
162
185
  test('completed turn receipt: Windows temporary ACL failure leaves the previous atomic store intact', (t) => {
163
186
  const box = sandbox();
164
187
  const env = { OS: 'Windows_NT', HOME: box.root, USERPROFILE: box.root };
@@ -16,6 +16,8 @@
16
16
  * export 経由で mock するため、named import に変えないこと。
17
17
  */
18
18
  import childProcess from 'node:child_process';
19
+ import { randomBytes } from 'node:crypto';
20
+ import { linkSync, rmSync, writeFileSync } from 'node:fs';
19
21
  import { platform as hostPlatform } from 'node:os';
20
22
 
21
23
  export const WINDOWS_ACL_TIMEOUT_MS = 15_000;
@@ -32,6 +34,31 @@ export function verifyWindowsAcl(path, directory) {
32
34
  runWindowsAclScript(path, directory, WINDOWS_ACL_VERIFY_SCRIPT);
33
35
  }
34
36
 
37
+ /**
38
+ * owner-only ACL の付いた空 file を、最終 path へ排他的に作る。作れたら true、既に在れば false。
39
+ *
40
+ * 最終 path へ作ってから ACL を適用すると、適用が終わるまで (PowerShell の起動で 1 秒前後)
41
+ * 継承 ACL のまま見える。同時に走った別 process がその間に verify して落ちた
42
+ * (fox 2026-10-05、Cursor が同じ Stop hook を 2 本同時に起動)。別名で作って ACL を
43
+ * 適用・検証し、hard link で公開する。hard link は同じ file なので ACL はそのまま付く。
44
+ */
45
+ export function createWindowsPrivateFile(path) {
46
+ const temporary = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
47
+ try {
48
+ writeFileSync(temporary, '', { encoding: 'utf8', flag: 'wx' });
49
+ applyAndVerifyWindowsAcl(temporary, false);
50
+ try {
51
+ linkSync(temporary, path);
52
+ return true;
53
+ } catch (error) {
54
+ if (error?.code !== 'EEXIST') throw error;
55
+ return false;
56
+ }
57
+ } finally {
58
+ rmSync(temporary, { force: true, maxRetries: 3, retryDelay: 50 });
59
+ }
60
+ }
61
+
35
62
  function runWindowsAclScript(path, directory, script) {
36
63
  const result = childProcess.spawnSync('pwsh.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
37
64
  env: { ...process.env, THROUGHLINE_ACL_PATH: path, THROUGHLINE_ACL_DIRECTORY: directory ? '1' : '0' },
@@ -15,7 +15,7 @@ import { dirname, join } from 'node:path';
15
15
  import { createRequire } from 'node:module';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { DatabaseSync } from 'node:sqlite';
18
- import { applyAndVerifyWindowsAcl, isWindows, verifyWindowsAcl } from './os/windows-acl.mjs';
18
+ import { applyAndVerifyWindowsAcl, createWindowsPrivateFile, isWindows, verifyWindowsAcl } from './os/windows-acl.mjs';
19
19
  import { windowsLocalAppData, xdgConfigHome, xdgStateHome } from './os/app-dirs.mjs';
20
20
 
21
21
  const require = createRequire(import.meta.url);
@@ -419,15 +419,16 @@ function withStoreLock(options, operation) {
419
419
  ensurePrivateStoreDirectory(directory, options.env);
420
420
  const lockPath = `${storePath}.lock.sqlite`;
421
421
  let created = false;
422
- try {
423
- writeFileSync(lockPath, '', { encoding: 'utf8', mode: 0o600, flag: 'wx' });
424
- created = true;
425
- } catch (error) {
426
- if (error?.code !== 'EEXIST') throw error;
427
- }
428
- if (created) {
429
- if (isWindows(options.env)) applyAndVerifyWindowsAcl(lockPath, false);
430
- else chmodSync(lockPath, 0o600);
422
+ if (isWindows(options.env)) {
423
+ created = !existsSync(lockPath) && createWindowsPrivateFile(lockPath);
424
+ } else {
425
+ try {
426
+ writeFileSync(lockPath, '', { encoding: 'utf8', mode: 0o600, flag: 'wx' });
427
+ created = true;
428
+ } catch (error) {
429
+ if (error?.code !== 'EEXIST') throw error;
430
+ }
431
+ if (created) chmodSync(lockPath, 0o600);
431
432
  }
432
433
  if (created && isWindows(options.env)) assertPrivateStoreFileShape(lstatSync(lockPath));
433
434
  else assertPrivateStoreFile(lstatSync(lockPath), options.env, lockPath);
@@ -1,7 +1,7 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import childProcess, { spawn } from 'node:child_process';
4
- import { chmodSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
4
+ import { chmodSync, existsSync, 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
 
@@ -142,6 +142,27 @@ test('runtime error store: one Windows mutation spends ACL processes only on dis
142
142
  assert.ok(calls.every((call) => call.options.timeout === 15_000));
143
143
  });
144
144
 
145
+ test('runtime error store: Windows lock file reaches its final path only after its ACL is applied', (t) => {
146
+ const box = sandbox();
147
+ box.env.OS = 'Windows_NT';
148
+ enableCollection(box);
149
+ const lockPath = `${box.storePath}.lock.sqlite`;
150
+ const applied = new Set();
151
+ const exposed = [];
152
+ // 同時に走る別のhookは、lockが見えた時点でそのACLを検証する。ACL processの起動を、その検証が入りうる瞬間として見る。
153
+ t.mock.method(childProcess, 'spawnSync', (command, args, options) => {
154
+ if (existsSync(lockPath) && !applied.has(statSync(lockPath, { bigint: true }).ino)) exposed.push(options.env.THROUGHLINE_ACL_PATH);
155
+ if (args.at(-1).includes('Set-Acl')) applied.add(statSync(options.env.THROUGHLINE_ACL_PATH, { bigint: true }).ino);
156
+ return { status: 0, signal: null, error: undefined };
157
+ });
158
+ const options = { env: box.env, configPath: box.configPath, storePath: box.storePath };
159
+
160
+ assert.equal(observeRuntimeError({ code: 'HOOK_CODEX_FAILED' }, options).status, 'recorded');
161
+ assert.deepEqual(exposed, [], 'the lock must not be visible with an inherited ACL');
162
+ assert.ok(applied.has(statSync(lockPath, { bigint: true }).ino));
163
+ assert.deepEqual(readdirSync(dirname(lockPath)).filter((name) => name.endsWith('.tmp')), []);
164
+ });
165
+
145
166
  test('runtime error store: Windows temporary ACL failure leaves the previous atomic store intact', (t) => {
146
167
  const box = sandbox();
147
168
  box.env.OS = 'Windows_NT';