throughline 0.14.1 → 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 +31 -1
- package/README.ja.md +1 -1
- package/README.md +1 -1
- package/docs/adr/0033-claude-auto-handoff-new-session.md +6 -1
- package/package.json +1 -1
- package/src/claude-auto-handoff.mjs +15 -2
- package/src/claude-auto-handoff.test.mjs +19 -3
- package/src/completed-turn-receipts.mjs +12 -10
- package/src/completed-turn-receipts.test.mjs +24 -1
- package/src/os/windows-acl.mjs +27 -0
- package/src/runtime-error-store.mjs +11 -10
- package/src/runtime-error-store.test.mjs +22 -1
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,34 @@ 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
|
+
|
|
27
|
+
## [0.14.2] — 2026-10-05
|
|
28
|
+
|
|
29
|
+
### 修正
|
|
30
|
+
|
|
31
|
+
- WindowsのClaude Codeの自動継続で、後継へ記憶が入らない不具合を直す。Claude CodeはWindowsのhookをGit Bashで走らせ、起動したprojectの場所を`C:/Users/…`の形で渡す。0.14.1までは、圧縮を止めるhookがこの形のまま印(baton)を残していた。後継の最初の指示は`C:\Users\…`の形で印を探すので見つからず、記憶を注入しなかった。継続の指示は届くので、後継は記憶の無いまま動いた。配送の結果は`unknown`(`handoff_delivery_timeout`)になった。
|
|
32
|
+
- 印と引き継ぎの記録に残すprojectの場所を、そのOSの書き方にそろえる。macOSとLinuxの動作は変わらない。
|
|
33
|
+
- 「ここまでにしたこと」の道具の対象を作業フォルダからの相対で書く処理も、Windowsで効くようになる。
|
|
34
|
+
|
|
35
|
+
### 確認した範囲
|
|
36
|
+
|
|
37
|
+
- Windows 11(10.0.26200)のClaude Code 2.1.289、Haiku 4.5。対話の会話から始めて、4回連続で引き継いで作業を完了した。読み直しも読み飛ばしも無く、どの会話でも自動圧縮は走っていない。0.14.1では、同じ条件で1回目の後継に記憶が入らなかった。
|
|
38
|
+
- 圧縮を止める・道具を止める・`claude --bg`で後継を立てる・受け口(named pipe)へ継続の指示を送る、の4つは、0.14.1の時点からWindowsで動いていた。
|
|
39
|
+
- Claude Desktopの画面から始めた会話、WindowsでのHaiku以外のモデル、subagentが動いている最中の引き継ぎは確かめていない。
|
|
40
|
+
|
|
13
41
|
## [0.14.1] — 2026-10-05
|
|
14
42
|
|
|
15
43
|
### 修正
|
|
@@ -1696,7 +1724,9 @@ two attempts, instrument first instead of patching again.
|
|
|
1696
1724
|
|
|
1697
1725
|
---
|
|
1698
1726
|
|
|
1699
|
-
[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
|
|
1729
|
+
[0.14.2]: https://github.com/kitepon/Throughline/compare/v0.14.1...v0.14.2
|
|
1700
1730
|
[0.14.1]: https://github.com/kitepon/Throughline/compare/v0.14.0...v0.14.1
|
|
1701
1731
|
[0.14.0]: https://github.com/kitepon/Throughline/compare/v0.13.0...v0.14.0
|
|
1702
1732
|
[0.13.0]: https://github.com/kitepon/Throughline/compare/v0.12.9...v0.13.0
|
package/README.ja.md
CHANGED
|
@@ -69,7 +69,7 @@ throughline auto-handoff status --host claude --operation <handoff-id> --json
|
|
|
69
69
|
|
|
70
70
|
後継の立ち上げや配送に失敗した時は、固定の理由を記録して止まります。結果が不明な指示は再送しません。圧縮を止めた後にモデルが道具を呼ばずにターンを終えた時は、後継を立てません。印は残るので、1時間以内に開いた新しい会話が記憶を引き継ぎます。
|
|
71
71
|
|
|
72
|
-
有効にした端末では、Claudeの道具の呼び出しのたびに`PreToolUse`フックが1回走ります。Linux
|
|
72
|
+
有効にした端末では、Claudeの道具の呼び出しのたびに`PreToolUse`フックが1回走ります。Linux・macOS・WindowsのClaude Code 2.1.289(macOSはClaude Desktop同梱の2.1.286も)で、端末から始めた会話を実機確認済みです。Claude Desktopの画面から始めた会話は未検証です。[実測と検証範囲](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-05-claude-auto-handoff.md)を参照してください。
|
|
73
73
|
|
|
74
74
|
## 所有境界
|
|
75
75
|
|
package/README.md
CHANGED
|
@@ -68,7 +68,7 @@ throughline auto-handoff status --host claude --operation <handoff-id> --json
|
|
|
68
68
|
|
|
69
69
|
後継の立ち上げや配送に失敗した時は、固定の理由を記録して止まります。結果が不明な指示は再送しません。圧縮を止めた後にモデルが道具を呼ばずにターンを終えた時は、後継を立てません。印は残るので、1時間以内に開いた新しい会話が記憶を引き継ぎます。
|
|
70
70
|
|
|
71
|
-
有効にした端末では、Claudeの道具の呼び出しのたびに`PreToolUse`フックが1回走ります。Linux
|
|
71
|
+
有効にした端末では、Claudeの道具の呼び出しのたびに`PreToolUse`フックが1回走ります。Linux・macOS・WindowsのClaude Code 2.1.289(macOSはClaude Desktop同梱の2.1.286も)で、端末から始めた会話を実機確認済みです。Claude Desktopの画面から始めた会話は未検証です。[実測と検証範囲](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-05-claude-auto-handoff.md)を参照してください。
|
|
72
72
|
|
|
73
73
|
## Ownership boundary
|
|
74
74
|
|
|
@@ -30,6 +30,8 @@ Claude Desktop の新しい会話は、画面を開いただけでは process
|
|
|
30
30
|
|
|
31
31
|
1. **圧縮を止める**: `PreCompact` hook(`throughline pre-compact`、matcher 無し)が、`trigger: "auto"` で自動継続が
|
|
32
32
|
有効な project の時だけ、`/tl` と同じ印(baton)と引き継ぎの記録を残し、exit code 2 で圧縮を止める。
|
|
33
|
+
印の project は、会話を起動した場所(`CLAUDE_PROJECT_DIR`)を、その OS の書き方にそろえたもの(0.14.2)。
|
|
34
|
+
hook の cwd は Bash の `cd` に追従するので使わない。後継は起動した場所で立ち、その cwd で印を探す。
|
|
33
35
|
手動の `/compact`、無効な project、subagent の中の圧縮(payload に `agent_id`)、script から起動した会話
|
|
34
36
|
(`CLAUDE_CODE_ENTRYPOINT` が `sdk-` で始まる)では止めない。
|
|
35
37
|
2. **旧い会話を止める**: `PreToolUse` hook(`throughline pre-tool-use`、全ての道具)が、記録のある会話の次の道具を、
|
|
@@ -101,4 +103,7 @@ Claude Desktop の新しい会話は、画面を開いただけでは process
|
|
|
101
103
|
- macOS 27.0。端末の会話(2.1.289)と、Claude Desktop が同梱する本体(2.1.286)を端末から起動した会話から始めた。
|
|
102
104
|
0.14.1 で、Haiku 4.5 は4回、Opus 5.5 は7回、連続で引き継いで完了した。読み直しも読み飛ばしも無い。
|
|
103
105
|
- どの会話にも圧縮の記録は無く、止めた道具は実行されていなかった。
|
|
104
|
-
-
|
|
106
|
+
- Windows 11。端末の会話(2.1.289)から始めた。0.14.2 で、Haiku 4.5 は4回、連続で引き継いで完了した。
|
|
107
|
+
0.14.1 までは、印(baton)に残す project の場所が `C:/…` の形で、後継が `C:\…` の形で探して見つけられなかった。
|
|
108
|
+
印と記録に残す場所は、その OS の書き方にそろえる(0.14.2)。
|
|
109
|
+
- Claude Desktop の画面から始めた会話、Fable、subagent が動いている最中の引き継ぎは確かめていない。
|
package/package.json
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { closeSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
20
20
|
import { homedir } from 'node:os';
|
|
21
|
-
import { basename, dirname, join, resolve } from 'node:path';
|
|
21
|
+
import { basename, dirname, join, posix, resolve, win32 } from 'node:path';
|
|
22
22
|
import { randomUUID } from 'node:crypto';
|
|
23
23
|
import { fileURLToPath } from 'node:url';
|
|
24
24
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
@@ -159,6 +159,17 @@ function unsupportedEntrypoint(env) {
|
|
|
159
159
|
return typeof env.CLAUDE_CODE_ENTRYPOINT === 'string' && env.CLAUDE_CODE_ENTRYPOINT.startsWith('sdk-');
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/**
|
|
163
|
+
* 会話を起動した project の場所を、その OS の書き方にそろえる。
|
|
164
|
+
*
|
|
165
|
+
* Windows の Claude Code は hook を Git Bash で走らせ、CLAUDE_PROJECT_DIR を `C:/Users/…` の形で渡す。
|
|
166
|
+
* hook の payload の cwd は `C:\Users\…` で、後継の最初の指示はこちらで印(baton)を探す。
|
|
167
|
+
* 書き方が違うと、後継が印を見つけられず、記憶が入らない。
|
|
168
|
+
*/
|
|
169
|
+
function nativeProjectPath(projectPath, platform) {
|
|
170
|
+
return (platform === 'win32' ? win32 : posix).resolve(projectPath);
|
|
171
|
+
}
|
|
172
|
+
|
|
162
173
|
/**
|
|
163
174
|
* PreCompact hook の本体。自動圧縮で、その project が有効な時だけ、印と記録を残して圧縮を止める。
|
|
164
175
|
*
|
|
@@ -177,6 +188,7 @@ export function requestClaudeAutoHandoff({
|
|
|
177
188
|
config = readClaudeAutoHandoffConfig(),
|
|
178
189
|
now = Date.now(),
|
|
179
190
|
dir = claudeAutoHandoffDir(),
|
|
191
|
+
platform = process.platform,
|
|
180
192
|
}) {
|
|
181
193
|
const sessionId = payload?.session_id;
|
|
182
194
|
if (typeof sessionId !== 'string' || !sessionId) throw new Error('Missing session_id in PreCompact payload');
|
|
@@ -185,7 +197,8 @@ export function requestClaudeAutoHandoff({
|
|
|
185
197
|
const path = recordPath(sessionId, dir);
|
|
186
198
|
const existing = readClaudeAutoHandoff(sessionId, dir);
|
|
187
199
|
// 有効判定は会話を起動した project で行う。hook の cwd は Bash の cd に追従する。
|
|
188
|
-
const projectPath =
|
|
200
|
+
const projectPath = nativeProjectPath(
|
|
201
|
+
claudeHostAdapter.completionProjectPath({ cwd: payload.cwd ?? process.cwd(), env }), platform);
|
|
189
202
|
|
|
190
203
|
if (payload.trigger !== 'auto') {
|
|
191
204
|
// 人が /compact を選んだ。まだ止めていない記録は取り下げる。
|
|
@@ -57,6 +57,8 @@ function request(dir, overrides = {}) {
|
|
|
57
57
|
payload: { session_id: SESSION, trigger: 'auto', cwd: '/work/app', transcript_path: overrides.transcriptPath ?? null,
|
|
58
58
|
...overrides.payload },
|
|
59
59
|
env: overrides.env ?? {}, config: overrides.config ?? ENABLED, openDb: () => db, dir, now: overrides.now ?? 1_000,
|
|
60
|
+
// 試験の project は POSIX の書き方(/work/app)。Windows の CI でも同じ値で比べる
|
|
61
|
+
platform: 'linux',
|
|
60
62
|
});
|
|
61
63
|
return { result, db };
|
|
62
64
|
}
|
|
@@ -84,6 +86,20 @@ test('PreCompact: 自動圧縮で有効な時だけ、印と記録を残して
|
|
|
84
86
|
assert.deepEqual([started.status, started.projectPath], ['requested', '/work/app']);
|
|
85
87
|
}));
|
|
86
88
|
|
|
89
|
+
test('PreCompact: Windows の `C:/…` の形の project は、後継が印を探す時の書き方(`C:\\…`)にそろえて残す', () => withDir(dir => {
|
|
90
|
+
const db = makeBatonDb();
|
|
91
|
+
const result = requestClaudeAutoHandoff({
|
|
92
|
+
// Windows の Git Bash で走る hook には、project の場所が `C:/…` の形で渡る(実機では CLAUDE_PROJECT_DIR)
|
|
93
|
+
payload: { session_id: SESSION, trigger: 'auto', cwd: 'C:/Users/k/proj/' },
|
|
94
|
+
env: {}, platform: 'win32',
|
|
95
|
+
config: { enabled: true, projects: [] }, openDb: () => db, dir, now: 1_000,
|
|
96
|
+
});
|
|
97
|
+
assert.equal(result.status, 'requested');
|
|
98
|
+
assert.equal(result.projectPath, 'C:\\Users\\k\\proj');
|
|
99
|
+
assert.equal(db.prepare('SELECT project_path FROM handoff_batons').get().project_path, 'C:\\Users\\k\\proj');
|
|
100
|
+
assert.equal(readClaudeAutoHandoff(SESSION, dir).project_path, 'C:\\Users\\k\\proj');
|
|
101
|
+
}));
|
|
102
|
+
|
|
87
103
|
test('PreCompact: 手動・無効・対象外のproject・scriptから起動した会話・subagent では圧縮を止めない', () => withDir(dir => {
|
|
88
104
|
for (const [overrides, reason] of [
|
|
89
105
|
[{ payload: { trigger: 'manual' } }, 'manual_compact'],
|
|
@@ -272,7 +288,7 @@ test('worker: 止めた時点の依頼とモデルを transcript から写し、
|
|
|
272
288
|
const run = async (sessionId, transcriptPath, toolUseId) => {
|
|
273
289
|
const db = makeBatonDb();
|
|
274
290
|
requestClaudeAutoHandoff({ payload: { session_id: sessionId, trigger: 'auto', cwd: '/work/app', transcript_path: transcriptPath },
|
|
275
|
-
env: {}, config: ENABLED, openDb: () => db, dir });
|
|
291
|
+
env: {}, config: ENABLED, openDb: () => db, dir, platform: 'linux' });
|
|
276
292
|
await stopClaudeTurnForHandoff({ payload: { session_id: sessionId, cwd: '/work/app', transcript_path: transcriptPath,
|
|
277
293
|
tool_use_id: toolUseId }, dir, launchWorker: async () => {} });
|
|
278
294
|
const calls = [];
|
|
@@ -381,7 +397,7 @@ test('worker: 止めたターンを DB へ取り込み、ここまでにした
|
|
|
381
397
|
]));
|
|
382
398
|
const db = makeMemoryDb();
|
|
383
399
|
requestClaudeAutoHandoff({ payload: { session_id: SESSION, trigger: 'auto', cwd: '/work/app', transcript_path: transcript },
|
|
384
|
-
env: {}, config: ENABLED, openDb: () => db, dir });
|
|
400
|
+
env: {}, config: ENABLED, openDb: () => db, dir, platform: 'linux' });
|
|
385
401
|
// hook は並んで走る。記録に残る止めた道具は、応答の中の2つ目のこともある
|
|
386
402
|
await stopClaudeTurnForHandoff({ payload: { session_id: SESSION, cwd: '/work/app', transcript_path: transcript, tool_use_id: 't5' },
|
|
387
403
|
dir, launchWorker: async () => {} });
|
|
@@ -428,7 +444,7 @@ test('worker: 止めたターンを取り込めなくても後継を立て、取
|
|
|
428
444
|
writeFileSync(transcript, jsonl([user('依頼', '2026-10-04T03:00:00Z'), assistant('途中です。', '2026-10-04T03:00:02Z'), toolUse('toolu_1', 'Read')]));
|
|
429
445
|
const db = makeBatonDb(); // bodies も sessions も無い
|
|
430
446
|
requestClaudeAutoHandoff({ payload: { session_id: SESSION, trigger: 'auto', cwd: '/work/app', transcript_path: transcript },
|
|
431
|
-
env: {}, config: ENABLED, openDb: () => db, dir });
|
|
447
|
+
env: {}, config: ENABLED, openDb: () => db, dir, platform: 'linux' });
|
|
432
448
|
await stopClaudeTurnForHandoff({ payload: { session_id: SESSION, cwd: '/work/app', transcript_path: transcript, tool_use_id: 'toolu_1' },
|
|
433
449
|
dir, launchWorker: async () => {} });
|
|
434
450
|
const record = await runClaudeAutoHandoffWorker(SESSION, { dir, env: {}, pollMs: 10, targetTimeoutMs: 300, transcriptTimeoutMs: 300,
|
|
@@ -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';
|