throughline 0.6.2 → 0.7.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/CHANGELOG.md +79 -4
- package/README.md +77 -25
- package/bin/throughline.mjs +14 -0
- package/docs/00_overview.md +12 -0
- package/docs/02_clear_auto_handoff_plan.md +39 -13
- package/docs/04_public_release_plan.md +2 -1
- package/docs/13_native_factory_diagnostics_plan.md +4 -2
- package/docs/14_observer_completed_turn_feed_plan.md +290 -0
- package/docs/BUGHUB_RUNTIME_ERROR_STORE_PLAN.md +31 -4
- package/docs/adr/0002-observer-claude-completion-receipt.md +42 -0
- package/docs/adr/0003-observer-completed-chain-cursor.md +34 -0
- package/docs/adr/0004-observer-db-pair-projection.md +71 -0
- package/docs/adr/0005-observer-read-pagination.md +51 -0
- package/docs/adr/0006-observer-page-offset-proof.md +33 -0
- package/docs/adr/0007-observer-read-cli-contract.md +61 -0
- package/docs/adr/0008-observer-wait-deadline-cancel.md +81 -0
- package/docs/adr/0009-observer-integration-regression-and-docs.md +37 -0
- package/docs/adr/0010-observer-o1-phase-acceptance.md +49 -0
- package/docs/adr/0011-observer-o1-control-lane-reconciliation.md +34 -0
- package/docs/adr/0012-claude-stop-transcript-flush-barrier.md +32 -0
- package/docs/adr/0013-observer-read-busy-writer-gate.md +46 -0
- package/docs/adr/0014-two-phase-handoff-ghost-baton.md +112 -0
- package/docs/adr/0015-l1-summarizer-model-effort-ratio.md +81 -0
- package/package.json +1 -1
- package/rag/01-hooks/hook-stdout-10k-persisted-output.md +48 -0
- package/rag/INDEX.md +4 -0
- package/src/auditor-context.mjs +92 -11
- package/src/auditor-context.test.mjs +116 -1
- package/src/baton.mjs +27 -7
- package/src/baton.test.mjs +44 -0
- package/src/body-digest.mjs +9 -0
- package/src/cli/auditor-context.test.mjs +1 -1
- package/src/cli/factory-diagnostics.mjs +5 -2
- package/src/cli/factory-diagnostics.test.mjs +31 -2
- package/src/cli/observer-read.mjs +73 -0
- package/src/cli/observer-read.test.mjs +93 -0
- package/src/cli/observer-wait.mjs +123 -0
- package/src/cli/observer-wait.test.mjs +167 -0
- package/src/codex-rollout-memory.mjs +13 -0
- package/src/codex-rollout-memory.test.mjs +27 -0
- package/src/codex-thread-index.mjs +1 -1
- package/src/codex-thread-index.test.mjs +18 -0
- package/src/completed-turn-receipts.mjs +373 -0
- package/src/completed-turn-receipts.test.mjs +186 -0
- package/src/db-schema.test.mjs +9 -2
- package/src/db.mjs +20 -1
- package/src/decision-log.mjs +24 -0
- package/src/factory-diagnostics.mjs +0 -1
- package/src/factory-diagnostics.test.mjs +18 -0
- package/src/haiku-summarizer.mjs +93 -16
- package/src/haiku-summarizer.test.mjs +118 -9
- package/src/handoff-executor.mjs +159 -0
- package/src/hook-entrypoints.test.mjs +192 -12
- package/src/observer-codex-projection.test.mjs +49 -0
- package/src/observer-turn-feed.mjs +392 -0
- package/src/observer-turn-feed.test.mjs +339 -0
- package/src/observer-turn-wait.mjs +102 -0
- package/src/observer-turn-wait.test.mjs +122 -0
- package/src/pending-handoff.mjs +96 -0
- package/src/pending-handoff.test.mjs +107 -0
- package/src/prompt-submit.mjs +46 -1
- package/src/resume-context.mjs +226 -62
- package/src/resume-context.test.mjs +134 -1
- package/src/runtime-error-store.mjs +74 -32
- package/src/runtime-error-store.test.mjs +51 -3
- package/src/session-start.mjs +70 -233
- package/src/transcript-reader.mjs +32 -0
- package/src/turn-backfill.mjs +3 -2
- package/src/turn-backfill.test.mjs +10 -4
- package/src/turn-processor.mjs +90 -1
- package/src/turn-processor.test.mjs +141 -0
- package/src/windows-acl-test-helper.mjs +29 -0
|
@@ -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
|
|