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.
|
|
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
|
@@ -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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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 };
|
package/src/os/windows-acl.mjs
CHANGED
|
@@ -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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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';
|