@gajae-code/coding-agent 0.18.5 → 0.18.7
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 +140 -0
- package/dist/types/capability/types.d.ts +4 -0
- package/dist/types/cli-commands.d.ts +10 -0
- package/dist/types/cli-ordinary.d.ts +13 -0
- package/dist/types/cli.d.ts +12 -3
- package/dist/types/commands/contribution-prep.d.ts +0 -3
- package/dist/types/commands/sdk.d.ts +3 -0
- package/dist/types/commands/update.d.ts +1 -0
- package/dist/types/config/model-profile-activation.d.ts +19 -0
- package/dist/types/config/settings.d.ts +4 -0
- package/dist/types/coordinator-mcp/session-reaper.d.ts +5 -0
- package/dist/types/discovery/helpers.d.ts +2 -2
- package/dist/types/edit/modes/replace.d.ts +3 -3
- package/dist/types/edit/streaming.d.ts +2 -2
- package/dist/types/exec/bash-shell-supervisor.d.ts +12 -0
- package/dist/types/runtime-mcp/elicitation.d.ts +12 -0
- package/dist/types/sdk/broker/discovery.d.ts +8 -0
- package/dist/types/sdk/broker/ensure.d.ts +1 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +4 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +9 -0
- package/dist/types/sdk/broker/runtime.d.ts +1 -1
- package/dist/types/sdk/broker/task-artifact-owner-validation.d.ts +55 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
- package/dist/types/sdk/bus/telegram-daemon.d.ts +4 -2
- package/dist/types/sdk/cli/diagnostics-cli.d.ts +18 -0
- package/dist/types/sdk/diagnostics/observe-broker.d.ts +185 -0
- package/dist/types/sdk/host/control/operations.d.ts +1 -1
- package/dist/types/sdk/host/host.d.ts +6 -0
- package/dist/types/sdk/index.d.ts +1 -0
- package/dist/types/sdk/router/session-router.d.ts +9 -0
- package/dist/types/sdk/session.d.ts +13 -0
- package/dist/types/session/agent-session.d.ts +5 -1
- package/dist/types/session/internal/managed-session-storage.d.ts +11 -1
- package/dist/types/session/internal/task-artifact-owner-access.d.ts +17 -0
- package/dist/types/session/internal/task-artifact-owner-transcript.d.ts +3 -0
- package/dist/types/session/session-retirement.d.ts +47 -0
- package/dist/types/session/session-storage.d.ts +0 -46
- package/dist/types/session/session-transcript-header.d.ts +2 -0
- package/dist/types/session/task-artifact-owner-codec.d.ts +107 -0
- package/dist/types/session/task-artifact-owner-retirement.d.ts +7 -0
- package/dist/types/session/task-artifact-owner.d.ts +10 -0
- package/dist/types/tools/ask-contract.d.ts +75 -254
- package/dist/types/tools/bash.d.ts +1 -1
- package/package.json +7 -7
- package/scripts/build-sdk-package-smoke.ts +5 -0
- package/scripts/resident-memory-bench.ts +174 -20
- package/src/capability/index.ts +1 -0
- package/src/capability/types.ts +4 -0
- package/src/cli/config-cli.ts +8 -18
- package/src/cli/public-command-entry.ts +5 -0
- package/src/cli/ssh-cli.ts +6 -3
- package/src/cli/web-search-cli.ts +9 -3
- package/src/cli-commands.ts +14 -0
- package/src/cli-main.ts +7 -8
- package/src/cli-ordinary.ts +197 -0
- package/src/cli.ts +25 -182
- package/src/commands/contribution-prep.ts +1 -3
- package/src/commands/sdk.ts +171 -21
- package/src/commands/update.ts +15 -1
- package/src/config/file-lock.ts +91 -2
- package/src/config/model-profile-activation.ts +70 -4
- package/src/config/model-profile-contract.ts +4 -1
- package/src/config/model-profiles.ts +23 -31
- package/src/config/model-resolver.ts +9 -1
- package/src/config/settings.ts +44 -4
- package/src/coordinator-mcp/event-webhook.ts +19 -14
- package/src/coordinator-mcp/policy.ts +14 -4
- package/src/coordinator-mcp/server.ts +142 -7
- package/src/coordinator-mcp/session-reaper.ts +9 -1
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
- package/src/discovery/helpers.ts +9 -3
- package/src/discovery/ssh.ts +8 -3
- package/src/edit/modes/replace.ts +6 -6
- package/src/edit/streaming.ts +15 -15
- package/src/exec/bash-shell-supervisor.ts +60 -3
- package/src/gjc-runtime/gc-runtime.ts +2 -1
- package/src/hindsight/config.ts +9 -3
- package/src/internal-urls/docs-index.generated.ts +11 -10
- package/src/memories/index.ts +8 -2
- package/src/modes/acp/acp-agent.ts +724 -370
- package/src/modes/components/tool-execution.ts +1 -1
- package/src/prompts/tools/ask.md +2 -0
- package/src/runtime-mcp/client.ts +13 -4
- package/src/runtime-mcp/elicitation.ts +142 -0
- package/src/sdk/broker/broker.ts +54 -1
- package/src/sdk/broker/daemon-entry.ts +67 -6
- package/src/sdk/broker/discovery.ts +8 -0
- package/src/sdk/broker/ensure.ts +156 -20
- package/src/sdk/broker/lifecycle-ledger.ts +110 -12
- package/src/sdk/broker/lifecycle.ts +334 -348
- package/src/sdk/broker/runtime.ts +5 -1
- package/src/sdk/broker/task-artifact-owner-validation.ts +259 -0
- package/src/sdk/broker/transport.ts +5 -1
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/chat-daemon-runtime.ts +3 -0
- package/src/sdk/bus/index.ts +3 -3
- package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
- package/src/sdk/bus/telegram-daemon-control.ts +15 -12
- package/src/sdk/bus/telegram-daemon.ts +38 -3
- package/src/sdk/cli/diagnostics-cli.ts +247 -0
- package/src/sdk/diagnostics/observe-broker.ts +654 -0
- package/src/sdk/host/control/dispatch.ts +52 -4
- package/src/sdk/host/control/operations.ts +1 -1
- package/src/sdk/host/host.ts +6 -0
- package/src/sdk/host/session-runtime.test.ts +83 -4
- package/src/sdk/host/session-runtime.ts +15 -7
- package/src/sdk/index.ts +1 -0
- package/src/sdk/lifecycle-session.ts +1 -0
- package/src/sdk/mcp/server.ts +15 -3
- package/src/sdk/protocol/operation-registry.ts +1 -1
- package/src/sdk/router/session-router.ts +26 -3
- package/src/sdk/session.ts +51 -8
- package/src/session/agent-session.ts +169 -43
- package/src/session/blob-store.ts +78 -33
- package/src/session/internal/managed-session-storage.ts +203 -4
- package/src/session/internal/task-artifact-owner-access.ts +229 -0
- package/src/session/internal/task-artifact-owner-transcript.ts +68 -0
- package/src/session/session-retirement.ts +155 -0
- package/src/session/session-storage.ts +1 -160
- package/src/session/session-transcript-header.ts +17 -0
- package/src/session/task-artifact-owner-codec.ts +578 -0
- package/src/session/task-artifact-owner-retirement.ts +475 -0
- package/src/session/task-artifact-owner.ts +289 -0
- package/src/setup/hermes/templates/operator-instructions.v1.md +19 -1
- package/src/setup/hermes-setup.ts +4 -1
- package/src/slash-commands/builtin-registry.ts +8 -5
- package/src/tools/ask-contract.test.ts +76 -1
- package/src/tools/ask-contract.ts +57 -6
- package/src/tools/ask.ts +16 -7
- package/src/tools/bash.ts +38 -29
- package/src/tools/descriptors.test.ts +29 -0
- package/src/tools/tool-catalog.generated.ts +7 -4
- package/src/web/search/index.ts +2 -2
|
@@ -4,6 +4,7 @@ import * as fsp from "node:fs/promises";
|
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { exactUnlink } from "@gajae-code/natives";
|
|
6
6
|
import { isEnoent, logger, postmortem } from "@gajae-code/utils";
|
|
7
|
+
import { readLinuxProcStartTimeSync } from "../gjc-runtime/linux-proc";
|
|
7
8
|
|
|
8
9
|
const BLOB_PREFIX = "blob:sha256:";
|
|
9
10
|
const CANONICAL_BLOB_NAME = /^[0-9a-f]{64}$/;
|
|
@@ -46,10 +47,21 @@ const RESIDENT_CACHE_SWEEP_EXPECTED_IO_CODES = new Set([
|
|
|
46
47
|
]);
|
|
47
48
|
const ownedResidentCacheInstanceDirs = new Set<string>();
|
|
48
49
|
const activeResidentCacheRootSweeps = new Set<string>();
|
|
49
|
-
let
|
|
50
|
+
let ownResidentCacheProcessStart: ResidentCacheProcessStart | undefined;
|
|
50
51
|
|
|
51
|
-
/** Marks `startTimeMs` as an absolute instant parsed from a UTC-pinned `ps` render. */
|
|
52
|
-
const
|
|
52
|
+
/** Marks `startTimeMs` as an absolute instant parsed from a UTC-pinned `ps` render (non-Linux). */
|
|
53
|
+
const RESIDENT_CACHE_UTC_START_TIME_BASIS = "utc";
|
|
54
|
+
/**
|
|
55
|
+
* Prefix for a Linux start identity: `startTimeMs` holds `/proc/<pid>/stat` field 22
|
|
56
|
+
* (clock ticks since boot) and the suffix is the kernel `boot_id`. Ticks are immune
|
|
57
|
+
* to wall-clock adjustment; the boot id scopes them to one kernel boot.
|
|
58
|
+
*/
|
|
59
|
+
const RESIDENT_CACHE_LINUX_START_TIME_BASIS_PREFIX = "linux-proc-ticks:";
|
|
60
|
+
|
|
61
|
+
interface ResidentCacheProcessStart {
|
|
62
|
+
readonly startTimeMs: number | null;
|
|
63
|
+
readonly basis: string;
|
|
64
|
+
}
|
|
53
65
|
|
|
54
66
|
interface ResidentCacheOwnerToken {
|
|
55
67
|
readonly pid: number;
|
|
@@ -240,19 +252,47 @@ function assertResidentCacheDirectoryPathMatchesDescriptor(pathname: string, des
|
|
|
240
252
|
assertResidentCacheDirectoryDescriptor(pathname, current, descriptor, uid);
|
|
241
253
|
}
|
|
242
254
|
|
|
243
|
-
|
|
244
|
-
if (pid === process.pid && ownResidentCacheProcessStartTimeMs !== undefined) {
|
|
245
|
-
return ownResidentCacheProcessStartTimeMs;
|
|
246
|
-
}
|
|
255
|
+
let residentCacheLinuxBootId: string | null | undefined;
|
|
247
256
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
257
|
+
function readResidentCacheLinuxBootId(): string | null {
|
|
258
|
+
if (residentCacheLinuxBootId !== undefined) return residentCacheLinuxBootId;
|
|
259
|
+
let bootId: string | null = null;
|
|
260
|
+
try {
|
|
261
|
+
const text = fs.readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
|
|
262
|
+
if (/^[0-9a-f-]{36}$/.test(text)) bootId = text;
|
|
263
|
+
} catch {
|
|
264
|
+
// Without a boot id, tick counts cannot be scoped to one boot.
|
|
265
|
+
}
|
|
266
|
+
residentCacheLinuxBootId = bootId;
|
|
267
|
+
return bootId;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function residentCacheProcessStart(pid: number): ResidentCacheProcessStart {
|
|
271
|
+
if (pid === process.pid && ownResidentCacheProcessStart !== undefined) return ownResidentCacheProcessStart;
|
|
272
|
+
const start = process.platform === "linux" ? residentCacheLinuxProcessStart(pid) : residentCacheUtcProcessStart(pid);
|
|
273
|
+
if (pid === process.pid) ownResidentCacheProcessStart = start;
|
|
274
|
+
return start;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// `ps -o lstart` is NOT a stable identity on Linux: procps renders it as
|
|
278
|
+
// `btime + starttime_ticks`, and `btime` is re-derived from the current wall clock
|
|
279
|
+
// minus uptime. On hosts whose clock is continuously slewed (WSL2 is the common
|
|
280
|
+
// case) `btime` drifts, so the very same live process reports a start time that
|
|
281
|
+
// moves by seconds to minutes over a session. Any drift reads as PID reuse and the
|
|
282
|
+
// GC reaps a running session's cache. The raw tick count never moves.
|
|
283
|
+
function residentCacheLinuxProcessStart(pid: number): ResidentCacheProcessStart {
|
|
284
|
+
const bootId = readResidentCacheLinuxBootId();
|
|
285
|
+
if (bootId === null) return { startTimeMs: null, basis: RESIDENT_CACHE_LINUX_START_TIME_BASIS_PREFIX };
|
|
286
|
+
const basis = `${RESIDENT_CACHE_LINUX_START_TIME_BASIS_PREFIX}${bootId}`;
|
|
287
|
+
const ticks = readLinuxProcStartTimeSync(pid);
|
|
288
|
+
const startTimeMs = ticks === null ? Number.NaN : Number(ticks);
|
|
289
|
+
return { startTimeMs: Number.isSafeInteger(startTimeMs) ? startTimeMs : null, basis };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function residentCacheUtcProcessStart(pid: number): ResidentCacheProcessStart {
|
|
293
|
+
// Pin the locale AND the zone. `lstart` is a zoneless local wall clock rendered
|
|
294
|
+
// by `ps` from /etc/localtime, while `Date.parse` of a zoneless string binds it
|
|
295
|
+
// to the JS runtime's own zone. Pinning TZ on the child fixes the rendering
|
|
256
296
|
// side; the explicit GMT suffix fixes the parsing side.
|
|
257
297
|
let startTimeMs: number | null = null;
|
|
258
298
|
try {
|
|
@@ -269,8 +309,7 @@ function residentCacheProcessStartTimeMs(pid: number): number | null {
|
|
|
269
309
|
} catch {
|
|
270
310
|
// An unavailable process-start source cannot prove PID reuse.
|
|
271
311
|
}
|
|
272
|
-
|
|
273
|
-
return startTimeMs;
|
|
312
|
+
return { startTimeMs, basis: RESIDENT_CACHE_UTC_START_TIME_BASIS };
|
|
274
313
|
}
|
|
275
314
|
|
|
276
315
|
type ResidentCacheOwnerLiveness = "alive" | "dead" | "unknown";
|
|
@@ -376,10 +415,11 @@ function readResidentCacheOwnerSnapshot(instanceDir: string, uid: number): Resid
|
|
|
376
415
|
function writeResidentCacheOwnerToken(instanceDir: string, uid: number): void {
|
|
377
416
|
const nonce = path.basename(instanceDir).slice("i-".length);
|
|
378
417
|
if (!nonce) throw new ResidentCacheTrustError("instance_nonce_missing", instanceDir);
|
|
418
|
+
const ownStart = residentCacheProcessStart(process.pid);
|
|
379
419
|
const owner: ResidentCacheOwnerToken = {
|
|
380
420
|
pid: process.pid,
|
|
381
|
-
startTimeMs:
|
|
382
|
-
startTimeBasis:
|
|
421
|
+
startTimeMs: ownStart.startTimeMs,
|
|
422
|
+
startTimeBasis: ownStart.basis,
|
|
383
423
|
nonce,
|
|
384
424
|
createdAt: Date.now(),
|
|
385
425
|
};
|
|
@@ -579,26 +619,31 @@ function residentCacheSweepLimit(value: number | undefined, ceiling: number): nu
|
|
|
579
619
|
return Math.max(0, Math.min(Math.floor(value), ceiling));
|
|
580
620
|
}
|
|
581
621
|
|
|
582
|
-
function
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
622
|
+
function cachedResidentCacheProcessStart(
|
|
623
|
+
pid: number,
|
|
624
|
+
cache: Map<number, ResidentCacheProcessStart>,
|
|
625
|
+
): ResidentCacheProcessStart {
|
|
626
|
+
const cached = cache.get(pid);
|
|
627
|
+
if (cached !== undefined) return cached;
|
|
628
|
+
const start = residentCacheProcessStart(pid);
|
|
629
|
+
cache.set(pid, start);
|
|
630
|
+
return start;
|
|
587
631
|
}
|
|
588
632
|
|
|
589
633
|
function residentCacheOwnerIsStale(
|
|
590
634
|
owner: ResidentCacheOwnerToken,
|
|
591
|
-
startTimeCache: Map<number,
|
|
635
|
+
startTimeCache: Map<number, ResidentCacheProcessStart>,
|
|
592
636
|
): boolean {
|
|
593
637
|
const liveness = residentCacheOwnerLiveness(owner.pid);
|
|
594
638
|
if (liveness === "dead") return true;
|
|
595
639
|
if (liveness !== "alive" || owner.startTimeMs === null) return false;
|
|
596
|
-
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
640
|
+
const current = cachedResidentCacheProcessStart(owner.pid, startTimeCache);
|
|
641
|
+
// Start times are comparable only on the same basis (and, on Linux, the same
|
|
642
|
+
// boot). A token from an older writer (unlabelled zone-dependent wall clock, or
|
|
643
|
+
// the drifting `ps` render) or another basis leaves reuse unproven, so a live
|
|
644
|
+
// owner is never reaped on a basis mismatch.
|
|
645
|
+
if (owner.startTimeBasis !== current.basis) return false;
|
|
646
|
+
return current.startTimeMs !== null && current.startTimeMs !== owner.startTimeMs;
|
|
602
647
|
}
|
|
603
648
|
|
|
604
649
|
function reapResidentCacheInstanceDir(
|
|
@@ -681,7 +726,7 @@ export async function sweepResidentCacheRoot(root: string, options: ResidentCach
|
|
|
681
726
|
// cache state behind.
|
|
682
727
|
rootDescriptor = openVerifiedResidentCacheDirectory(root, uid);
|
|
683
728
|
assertResidentCacheDirectoryPathMatchesDescriptor(root, rootDescriptor, uid);
|
|
684
|
-
const startTimeCache = new Map<number,
|
|
729
|
+
const startTimeCache = new Map<number, ResidentCacheProcessStart>();
|
|
685
730
|
const entries = fs.opendirSync(root);
|
|
686
731
|
try {
|
|
687
732
|
let examined = 0;
|
|
@@ -746,7 +791,7 @@ function openVerifiedCacheInstanceDir(root: string, instanceName?: string): stri
|
|
|
746
791
|
const staleInstance = readResidentCacheOwnerSnapshot(candidate, uid);
|
|
747
792
|
if (
|
|
748
793
|
staleInstance === null ||
|
|
749
|
-
!residentCacheOwnerIsStale(staleInstance.owner, new Map<number,
|
|
794
|
+
!residentCacheOwnerIsStale(staleInstance.owner, new Map<number, ResidentCacheProcessStart>()) ||
|
|
750
795
|
!reapResidentCacheInstanceDir(root, rootDescriptor, candidate, staleInstance, uid)
|
|
751
796
|
) {
|
|
752
797
|
throw error;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as fsp from "node:fs/promises";
|
|
4
|
+
import * as os from "node:os";
|
|
4
5
|
import * as path from "node:path";
|
|
5
6
|
import type {
|
|
6
7
|
NativeDirectoryTreeSnapshot,
|
|
@@ -11,6 +12,7 @@ import type {
|
|
|
11
12
|
RecoveryFsRoot,
|
|
12
13
|
} from "@gajae-code/natives";
|
|
13
14
|
import { isEnoent, logger } from "@gajae-code/utils";
|
|
15
|
+
import { isProcessIncarnation, observeProcessIncarnation } from "../../sdk/broker/process-incarnation";
|
|
14
16
|
import type { SessionStorageRangeSnapshot, SessionStorageStat } from "../session-storage";
|
|
15
17
|
import {
|
|
16
18
|
classifyNativePublishOutcome,
|
|
@@ -370,8 +372,69 @@ type ReplacementCleanupReceipt = {
|
|
|
370
372
|
destination: string;
|
|
371
373
|
successor: SerializedReplacementIdentity;
|
|
372
374
|
predecessor: SerializedReplacementIdentity;
|
|
375
|
+
publisher?: ReplacementReceiptPublisher;
|
|
373
376
|
};
|
|
374
377
|
|
|
378
|
+
type ReplacementReceiptPublisher = {
|
|
379
|
+
attemptId: string;
|
|
380
|
+
ownerId: string;
|
|
381
|
+
pid: number;
|
|
382
|
+
incarnation: string;
|
|
383
|
+
host: string;
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
// A process can contain multiple JS isolates. Only this exact owner may infer
|
|
387
|
+
// that an absent local attempt has settled; another isolate's empty set is not
|
|
388
|
+
// evidence that its sibling has finished publishing.
|
|
389
|
+
const replacementPublisherOwnerId = randomUUID();
|
|
390
|
+
const activeReplacementAttempts = new Set<string>();
|
|
391
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
392
|
+
|
|
393
|
+
function replacementReceiptPublisher(bytes: Uint8Array): ReplacementReceiptPublisher | undefined {
|
|
394
|
+
let record: Record<string, unknown> | undefined;
|
|
395
|
+
try {
|
|
396
|
+
record = asRecord(JSON.parse(Buffer.from(bytes).toString("utf8")));
|
|
397
|
+
} catch {
|
|
398
|
+
return undefined;
|
|
399
|
+
}
|
|
400
|
+
if (!record || !Object.hasOwn(record, "publisher")) return undefined;
|
|
401
|
+
const publisher = asRecord(record.publisher);
|
|
402
|
+
if (
|
|
403
|
+
!publisher ||
|
|
404
|
+
typeof publisher.attemptId !== "string" ||
|
|
405
|
+
!UUID_PATTERN.test(publisher.attemptId) ||
|
|
406
|
+
typeof publisher.ownerId !== "string" ||
|
|
407
|
+
!UUID_PATTERN.test(publisher.ownerId) ||
|
|
408
|
+
!Number.isSafeInteger(publisher.pid) ||
|
|
409
|
+
Number(publisher.pid) <= 0 ||
|
|
410
|
+
Number(publisher.pid) > 0x7fff_ffff ||
|
|
411
|
+
!isProcessIncarnation(publisher.incarnation) ||
|
|
412
|
+
typeof publisher.host !== "string" ||
|
|
413
|
+
!publisher.host ||
|
|
414
|
+
typeof record.destination !== "string" ||
|
|
415
|
+
typeof record.staging !== "string" ||
|
|
416
|
+
path.resolve(record.staging) !==
|
|
417
|
+
path.join(
|
|
418
|
+
path.dirname(path.resolve(record.destination)),
|
|
419
|
+
`.${path.basename(record.destination)}.${publisher.attemptId}.replacement`,
|
|
420
|
+
)
|
|
421
|
+
)
|
|
422
|
+
throw new Error("managed_replace_cleanup_receipt_invalid");
|
|
423
|
+
return publisher as ReplacementReceiptPublisher;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Unknown/remote/stopped owners retain their receipt; elapsed time is not death proof. */
|
|
427
|
+
function replacementReceiptIsOwned(bytes: Uint8Array): boolean {
|
|
428
|
+
const publisher = replacementReceiptPublisher(bytes);
|
|
429
|
+
if (!publisher) return false;
|
|
430
|
+
if (publisher.host !== os.hostname()) return true;
|
|
431
|
+
if (publisher.pid === process.pid && publisher.ownerId === replacementPublisherOwnerId)
|
|
432
|
+
return activeReplacementAttempts.has(publisher.attemptId);
|
|
433
|
+
const observed = observeProcessIncarnation(publisher.pid);
|
|
434
|
+
if (observed.status === "absent") return false;
|
|
435
|
+
return observed.status !== "present" || observed.incarnation === publisher.incarnation;
|
|
436
|
+
}
|
|
437
|
+
|
|
375
438
|
type LegacyReplacementCleanupIdentity = {
|
|
376
439
|
dev: bigint;
|
|
377
440
|
ino: bigint;
|
|
@@ -1411,6 +1474,7 @@ export class ManagedSessionDescendantStore {
|
|
|
1411
1474
|
retained?: { authority: RecoveryFsRoot; authorityBaseDir: string },
|
|
1412
1475
|
policy?: ManagedSessionSecurityPolicy,
|
|
1413
1476
|
profileAgentDir?: string,
|
|
1477
|
+
expectedSubtreeRoot?: ManagedDirectoryRoot,
|
|
1414
1478
|
) {
|
|
1415
1479
|
managedRelativePath(root, baseDir);
|
|
1416
1480
|
|
|
@@ -1449,13 +1513,46 @@ export class ManagedSessionDescendantStore {
|
|
|
1449
1513
|
});
|
|
1450
1514
|
}
|
|
1451
1515
|
this.#authority = retained.authority;
|
|
1516
|
+
if (
|
|
1517
|
+
expectedSubtreeRoot &&
|
|
1518
|
+
(expectedSubtreeRoot.canonicalPath !== this.#baseDir ||
|
|
1519
|
+
expectedSubtreeRoot.dev !== this.#subtreeRoot.dev ||
|
|
1520
|
+
expectedSubtreeRoot.ino !== this.#subtreeRoot.ino)
|
|
1521
|
+
)
|
|
1522
|
+
throw new Error("Managed subtree authority changed during establishment");
|
|
1452
1523
|
this.#assertBound();
|
|
1453
1524
|
|
|
1454
1525
|
return;
|
|
1455
1526
|
}
|
|
1456
1527
|
assertManagedDirectoryRoot(root);
|
|
1457
|
-
|
|
1528
|
+
if (expectedSubtreeRoot) {
|
|
1529
|
+
if (expectedSubtreeRoot.canonicalPath !== this.#baseDir)
|
|
1530
|
+
throw new Error("Managed subtree authority path mismatch");
|
|
1531
|
+
assertManagedDirectoryRoot(expectedSubtreeRoot);
|
|
1532
|
+
const verified = validateNativeSecurityResult(
|
|
1533
|
+
process.platform === "win32"
|
|
1534
|
+
? nativeSessionStorage().verifyOwnerOnlyPathSecurityExpected(
|
|
1535
|
+
this.#baseDir,
|
|
1536
|
+
"directory",
|
|
1537
|
+
expectedSubtreeRoot.dev,
|
|
1538
|
+
expectedSubtreeRoot.ino,
|
|
1539
|
+
)
|
|
1540
|
+
: nativeSessionStorage().verifyOwnerOnlyPathSecurity(this.#baseDir, "directory"),
|
|
1541
|
+
"verify",
|
|
1542
|
+
"directory",
|
|
1543
|
+
);
|
|
1544
|
+
if (!verified.ok) throw securityError(this.#baseDir, verified);
|
|
1545
|
+
assertManagedDirectoryRoot(expectedSubtreeRoot);
|
|
1546
|
+
} else {
|
|
1547
|
+
ensureManagedDirectory(this.#baseDir, root, this.#policy);
|
|
1548
|
+
}
|
|
1458
1549
|
const subtreeStat = fs.lstatSync(this.#baseDir, { bigint: true });
|
|
1550
|
+
if (
|
|
1551
|
+
expectedSubtreeRoot &&
|
|
1552
|
+
(canonicalFileId(subtreeStat.dev) !== expectedSubtreeRoot.dev ||
|
|
1553
|
+
canonicalFileId(subtreeStat.ino) !== expectedSubtreeRoot.ino)
|
|
1554
|
+
)
|
|
1555
|
+
throw new Error("Managed subtree authority changed during establishment");
|
|
1459
1556
|
this.#subtreeRoot = Object.freeze({
|
|
1460
1557
|
canonicalPath: this.#baseDir,
|
|
1461
1558
|
dev: canonicalFileId(subtreeStat.dev),
|
|
@@ -1571,6 +1668,50 @@ export class ManagedSessionDescendantStore {
|
|
|
1571
1668
|
}
|
|
1572
1669
|
this.#assertBound();
|
|
1573
1670
|
}
|
|
1671
|
+
|
|
1672
|
+
/** Capture a directory identity through this store's retained managed root without repairing it. */
|
|
1673
|
+
captureDirectoryIdentity(relativePath: string): { dev: string; ino: string } {
|
|
1674
|
+
this.#assertBound();
|
|
1675
|
+
const resolved = this.#resolve(relativePath);
|
|
1676
|
+
if (!this.#authority) this.#assertPathBackedDirectoryChain(resolved);
|
|
1677
|
+
const named = fs.lstatSync(resolved, { bigint: true });
|
|
1678
|
+
if (!named.isDirectory() || named.isSymbolicLink()) throw new Error("managed_directory_identity_unavailable");
|
|
1679
|
+
const dev = canonicalFileId(named.dev);
|
|
1680
|
+
const ino = canonicalFileId(named.ino);
|
|
1681
|
+
if (dev !== this.#subtreeRoot.dev) throw new Error("managed_directory_identity_unavailable");
|
|
1682
|
+
if (this.#authority) {
|
|
1683
|
+
const retainedRelative = this.#relative(resolved);
|
|
1684
|
+
if (retainedRelative === "") {
|
|
1685
|
+
if (dev !== this.#subtreeRoot.dev || ino !== this.#subtreeRoot.ino)
|
|
1686
|
+
throw new Error("Managed descendant root binding changed");
|
|
1687
|
+
} else {
|
|
1688
|
+
const retained = this.#authority.retainManagedDirectory(retainedRelative, dev.toString(), ino.toString());
|
|
1689
|
+
try {
|
|
1690
|
+
const identity = retained.identity();
|
|
1691
|
+
if (
|
|
1692
|
+
!identity.ok ||
|
|
1693
|
+
!identity.identity ||
|
|
1694
|
+
identity.identity.dev !== dev.toString() ||
|
|
1695
|
+
identity.identity.ino !== ino.toString()
|
|
1696
|
+
)
|
|
1697
|
+
throw new Error(identity.code ?? "managed_directory_identity_unavailable");
|
|
1698
|
+
} finally {
|
|
1699
|
+
retained.close();
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
const after = fs.lstatSync(resolved, { bigint: true });
|
|
1704
|
+
if (
|
|
1705
|
+
!after.isDirectory() ||
|
|
1706
|
+
after.isSymbolicLink() ||
|
|
1707
|
+
canonicalFileId(after.dev) !== dev ||
|
|
1708
|
+
canonicalFileId(after.ino) !== ino
|
|
1709
|
+
)
|
|
1710
|
+
throw new Error("managed_directory_identity_changed");
|
|
1711
|
+
this.#assertBound();
|
|
1712
|
+
return { dev: dev.toString(), ino: ino.toString() };
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1574
1715
|
#assertBound(): void {
|
|
1575
1716
|
if (!this.#authority) {
|
|
1576
1717
|
const named = fs.statSync(this.#baseDir, { bigint: true });
|
|
@@ -1668,6 +1809,7 @@ export class ManagedSessionDescendantStore {
|
|
|
1668
1809
|
if (this.#recoverReplacementCleanupPlaceholder(receiptPath, binding, receipt)) return;
|
|
1669
1810
|
throw new Error("managed_replace_cleanup_receipt_invalid");
|
|
1670
1811
|
}
|
|
1812
|
+
if (replacementReceiptIsOwned(receipt.bytes)) return;
|
|
1671
1813
|
const predecessor = binding.predecessor;
|
|
1672
1814
|
const quarantineName = replacementReceiptRetirementName(receipt.identity, predecessor);
|
|
1673
1815
|
const detached = nativeSessionStorage().exactUnlink(receiptPath, {
|
|
@@ -1770,7 +1912,14 @@ export class ManagedSessionDescendantStore {
|
|
|
1770
1912
|
}
|
|
1771
1913
|
|
|
1772
1914
|
#reconcilePendingReplacementReceipt(receiptPath: string): void {
|
|
1773
|
-
|
|
1915
|
+
let pending: ManagedFileSnapshot;
|
|
1916
|
+
try {
|
|
1917
|
+
pending = captureManagedFilePrefixNoFollow(receiptPath, REPLACEMENT_CLEANUP_RECEIPT_MAX_BYTES);
|
|
1918
|
+
} catch (error) {
|
|
1919
|
+
// Directory enumeration is not a claim on a peer's receipt name.
|
|
1920
|
+
if (isEnoent(error)) return;
|
|
1921
|
+
throw error;
|
|
1922
|
+
}
|
|
1774
1923
|
const parsed = parseReplacementCleanupReceipt(pending.bytes);
|
|
1775
1924
|
const predecessor = parsed ? parseReplacementIdentity(parsed.predecessor) : undefined;
|
|
1776
1925
|
if (
|
|
@@ -1780,6 +1929,7 @@ export class ManagedSessionDescendantStore {
|
|
|
1780
1929
|
path.dirname(path.resolve(parsed.destination)) !== this.#baseDir
|
|
1781
1930
|
)
|
|
1782
1931
|
throw new Error("managed_replace_cleanup_receipt_invalid");
|
|
1932
|
+
if (replacementReceiptIsOwned(pending.bytes)) return;
|
|
1783
1933
|
const destination = replacementReceiptPath(this.#baseDir, predecessor, pending.identity);
|
|
1784
1934
|
const outcome = classifyNativePublishOutcome(
|
|
1785
1935
|
nativeSessionStorage().renameNoReplacePath(receiptPath, destination),
|
|
@@ -1788,7 +1938,11 @@ export class ManagedSessionDescendantStore {
|
|
|
1788
1938
|
fsyncDirectory(this.#baseDir);
|
|
1789
1939
|
return;
|
|
1790
1940
|
}
|
|
1791
|
-
if (
|
|
1941
|
+
if (
|
|
1942
|
+
outcome.reason === "destination_exists" ||
|
|
1943
|
+
outcome.reason === "invalid_request" ||
|
|
1944
|
+
(outcome.reason === "identity_violation" && outcome.mutationState === "not_committed")
|
|
1945
|
+
) {
|
|
1792
1946
|
const captureIfPresent = (pathname: string): ManagedFileSnapshot | undefined => {
|
|
1793
1947
|
try {
|
|
1794
1948
|
return captureManagedFilePrefixNoFollow(pathname, REPLACEMENT_CLEANUP_RECEIPT_MAX_BYTES);
|
|
@@ -2943,6 +3097,34 @@ export class ManagedSessionDescendantStore {
|
|
|
2943
3097
|
if (!removed.ok) throw new Error(removed.code ?? "managed_remove_failed");
|
|
2944
3098
|
this.#assertBound();
|
|
2945
3099
|
}
|
|
3100
|
+
|
|
3101
|
+
/** Remove one exact managed tree through the parent-identity-bound native protocol. */
|
|
3102
|
+
removeTreeExpectedWithParentIdentity(
|
|
3103
|
+
relativePath: string,
|
|
3104
|
+
expected: NativeDirectoryTreeSnapshot,
|
|
3105
|
+
parentIdentity: { dev: bigint; ino: bigint },
|
|
3106
|
+
): NativeExactUnlinkResult {
|
|
3107
|
+
this.#beforeMutation();
|
|
3108
|
+
this.#assertBound();
|
|
3109
|
+
const resolved = this.#resolve(relativePath);
|
|
3110
|
+
const parentPath = path.dirname(resolved);
|
|
3111
|
+
const parentRelative = path.relative(this.#baseDir, parentPath);
|
|
3112
|
+
if (path.isAbsolute(parentRelative) || parentRelative === ".." || parentRelative.startsWith(`..${path.sep}`))
|
|
3113
|
+
throw new Error("managed_remove_parent_outside_store");
|
|
3114
|
+
const currentParent = this.captureDirectoryIdentity(parentRelative);
|
|
3115
|
+
if (
|
|
3116
|
+
currentParent.dev !== canonicalFileId(parentIdentity.dev).toString() ||
|
|
3117
|
+
currentParent.ino !== canonicalFileId(parentIdentity.ino).toString()
|
|
3118
|
+
)
|
|
3119
|
+
throw new Error("managed_remove_parent_identity_mismatch");
|
|
3120
|
+
const removed = nativeSessionStorage().exactRemoveDirectoryTree(resolved, expected, {
|
|
3121
|
+
dev: BigInt(currentParent.dev),
|
|
3122
|
+
ino: BigInt(currentParent.ino),
|
|
3123
|
+
});
|
|
3124
|
+
this.#assertBound();
|
|
3125
|
+
return removed;
|
|
3126
|
+
}
|
|
3127
|
+
|
|
2946
3128
|
fsyncTree(): NativeDirectoryTreeSnapshot {
|
|
2947
3129
|
this.#beforeMutation();
|
|
2948
3130
|
this.#assertBound();
|
|
@@ -3524,7 +3706,21 @@ function replaceManagedFileGeneratedSync(
|
|
|
3524
3706
|
): ManagedFileSnapshot["identity"] {
|
|
3525
3707
|
const parent = path.dirname(destination);
|
|
3526
3708
|
ensureManagedDirectory(parent, root, policy);
|
|
3527
|
-
const
|
|
3709
|
+
const attemptId = randomUUID();
|
|
3710
|
+
const staging = path.join(parent, `.${path.basename(destination)}.${attemptId}.replacement`);
|
|
3711
|
+
const publisherObservation = expectedDestination ? observeProcessIncarnation(process.pid) : undefined;
|
|
3712
|
+
if (expectedDestination && publisherObservation?.status !== "present")
|
|
3713
|
+
throw new Error("managed_replace_publisher_identity_unavailable");
|
|
3714
|
+
const publisher: ReplacementReceiptPublisher | undefined =
|
|
3715
|
+
publisherObservation?.status === "present"
|
|
3716
|
+
? {
|
|
3717
|
+
attemptId,
|
|
3718
|
+
ownerId: replacementPublisherOwnerId,
|
|
3719
|
+
pid: process.pid,
|
|
3720
|
+
incarnation: publisherObservation.incarnation,
|
|
3721
|
+
host: os.hostname(),
|
|
3722
|
+
}
|
|
3723
|
+
: undefined;
|
|
3528
3724
|
let fd: number | undefined;
|
|
3529
3725
|
let stagedIdentity: { dev: bigint; ino: bigint } | undefined;
|
|
3530
3726
|
let preserveStaging = false;
|
|
@@ -3542,6 +3738,7 @@ function replaceManagedFileGeneratedSync(
|
|
|
3542
3738
|
let publicationDurable = false;
|
|
3543
3739
|
let publicationCommitted = false;
|
|
3544
3740
|
let expectedSuccessor: ManagedFileSnapshot["identity"] | undefined;
|
|
3741
|
+
activeReplacementAttempts.add(attemptId);
|
|
3545
3742
|
try {
|
|
3546
3743
|
fd = fs.openSync(
|
|
3547
3744
|
staging,
|
|
@@ -3578,6 +3775,7 @@ function replaceManagedFileGeneratedSync(
|
|
|
3578
3775
|
destination,
|
|
3579
3776
|
successor: serializeReplacementIdentity(successor),
|
|
3580
3777
|
predecessor: serializeReplacementIdentity(expectedDestination),
|
|
3778
|
+
publisher,
|
|
3581
3779
|
} satisfies ReplacementCleanupReceipt),
|
|
3582
3780
|
),
|
|
3583
3781
|
root,
|
|
@@ -3733,6 +3931,7 @@ function replaceManagedFileGeneratedSync(
|
|
|
3733
3931
|
}
|
|
3734
3932
|
}
|
|
3735
3933
|
}
|
|
3934
|
+
activeReplacementAttempts.delete(attemptId);
|
|
3736
3935
|
}
|
|
3737
3936
|
if (failure !== undefined && !(acceptCommittedCleanupFailure && publicationDurable && publishedIdentity))
|
|
3738
3937
|
throw failure;
|