@gajae-code/coding-agent 0.12.19 → 0.12.20
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 +20 -9
- package/dist/types/commands/gc.d.ts +4 -0
- package/dist/types/commands/sdk.d.ts +66 -0
- package/dist/types/config/settings-schema.d.ts +34 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +97 -1
- package/dist/types/modes/acp/acp-agent.d.ts +33 -1
- package/dist/types/modes/components/bash-execution.d.ts +7 -0
- package/dist/types/modes/components/eval-execution.d.ts +7 -0
- package/dist/types/modes/utils/ui-helpers.d.ts +10 -1
- package/dist/types/sdk/acp/adapter.d.ts +9 -17
- package/dist/types/sdk/acp/index.d.ts +1 -1
- package/dist/types/sdk/broker/broker.d.ts +93 -1
- package/dist/types/sdk/broker/ensure.d.ts +17 -0
- package/dist/types/sdk/broker/index.d.ts +1 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +10 -1
- package/dist/types/sdk/broker/lifecycle.d.ts +81 -2
- package/dist/types/sdk/broker/startup-budget.d.ts +44 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +7 -1
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +11 -1
- package/dist/types/sdk/bus/discord-daemon.d.ts +1 -0
- package/dist/types/sdk/bus/index.d.ts +20 -9
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -6
- package/dist/types/sdk/bus/slack-daemon.d.ts +2 -2
- package/dist/types/sdk/host/query/revision-store.d.ts +5 -0
- package/dist/types/sdk/host/session-runtime.d.ts +4 -0
- package/dist/types/sdk/lifecycle-session.d.ts +13 -2
- package/dist/types/sdk/prompt-failure.d.ts +13 -0
- package/dist/types/sdk/prompt-watchdog.d.ts +82 -0
- package/dist/types/sdk/session-reconnect.d.ts +20 -0
- package/dist/types/sdk/startup-capability.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +14 -4
- package/dist/types/session/blob-store.d.ts +32 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +40 -2
- package/dist/types/session/internal/session-memory-sidecar.d.ts +881 -0
- package/dist/types/session/session-manager.d.ts +301 -27
- package/dist/types/session/session-storage.d.ts +170 -0
- package/dist/types/task/executor.d.ts +2 -1
- package/dist/types/tools/bash.d.ts +1 -0
- package/dist/types/tui/output-block.d.ts +6 -0
- package/package.json +8 -8
- package/src/cli/args.ts +75 -34
- package/src/commands/acp.ts +7 -1
- package/src/commands/gc.ts +31 -2
- package/src/commands/sdk.ts +141 -7
- package/src/config/settings-schema.ts +43 -0
- package/src/coordinator-mcp/server.ts +6 -1
- package/src/export/html/index.ts +83 -39
- package/src/gjc-runtime/gc-runtime.ts +1283 -4
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/internal-urls/local-protocol.ts +14 -0
- package/src/main.ts +40 -9
- package/src/modes/acp/acp-agent.ts +564 -37
- package/src/modes/acp/terminal-auth.ts +2 -2
- package/src/modes/components/bash-execution.ts +14 -0
- package/src/modes/components/eval-execution.ts +14 -0
- package/src/modes/controllers/command-controller.ts +63 -8
- package/src/modes/utils/ui-helpers.ts +81 -11
- package/src/sdk/acp/adapter.ts +46 -45
- package/src/sdk/acp/index.ts +1 -1
- package/src/sdk/broker/broker.ts +392 -11
- package/src/sdk/broker/ensure.ts +131 -61
- package/src/sdk/broker/index.ts +1 -0
- package/src/sdk/broker/lifecycle-ledger.ts +59 -5
- package/src/sdk/broker/lifecycle.ts +278 -63
- package/src/sdk/broker/startup-budget.ts +103 -0
- package/src/sdk/bus/chat-daemon-control.ts +9 -3
- package/src/sdk/bus/chat-daemon-runtime.ts +565 -29
- package/src/sdk/bus/conversation-store.ts +1 -1
- package/src/sdk/bus/discord-daemon.ts +2 -1
- package/src/sdk/bus/discord-live-provider.ts +34 -6
- package/src/sdk/bus/index.ts +118 -20
- package/src/sdk/bus/kind-aware-reconciliation.ts +2 -1
- package/src/sdk/bus/prompt-reconciliation.ts +4 -9
- package/src/sdk/bus/slack-daemon.ts +91 -18
- package/src/sdk/cli/session-cli.ts +8 -1
- package/src/sdk/host/query/handlers.ts +1 -1
- package/src/sdk/host/query/revision-store.ts +48 -12
- package/src/sdk/host/session-runtime.test.ts +50 -0
- package/src/sdk/host/session-runtime.ts +12 -2
- package/src/sdk/lifecycle-session.ts +26 -3
- package/src/sdk/mcp/server.ts +6 -1
- package/src/sdk/prompt-failure.ts +42 -0
- package/src/sdk/prompt-watchdog.ts +162 -0
- package/src/sdk/session-reconnect.ts +43 -0
- package/src/sdk/startup-capability.ts +13 -4
- package/src/session/agent-session.ts +525 -222
- package/src/session/blob-store.ts +89 -0
- package/src/session/internal/managed-session-scope.ts +36 -26
- package/src/session/internal/managed-session-storage.ts +303 -32
- package/src/session/internal/session-memory-sidecar.ts +2160 -0
- package/src/session/session-manager.ts +7027 -394
- package/src/session/session-storage.ts +1134 -11
- package/src/task/executor.ts +17 -4
- package/src/tools/bash.ts +109 -7
- package/src/tools/find.ts +55 -26
- package/src/tools/todo-contract.ts +40 -8
- package/src/tui/output-block.ts +11 -1
|
@@ -1523,3 +1523,92 @@ export function resolveResidentImageDataSync(
|
|
|
1523
1523
|
}
|
|
1524
1524
|
return buffer.toString("base64");
|
|
1525
1525
|
}
|
|
1526
|
+
|
|
1527
|
+
// =============================================================================
|
|
1528
|
+
// Canonical-store mark-and-sweep primitives (`gjc gc --disk`)
|
|
1529
|
+
// =============================================================================
|
|
1530
|
+
//
|
|
1531
|
+
// The resident-cache sweep above bounds *cache* directories. These primitives
|
|
1532
|
+
// expose the canonical `~/.gjc/agent/blobs` store to an out-of-process retention
|
|
1533
|
+
// pass so it can mark live hashes from surviving transcripts and sweep the rest.
|
|
1534
|
+
// They own no policy: the caller decides what "unreferenced" means.
|
|
1535
|
+
|
|
1536
|
+
/** One canonical blob file (`<blobsDir>/<sha256-hex>`) observed on disk. */
|
|
1537
|
+
export interface CanonicalBlobEntry {
|
|
1538
|
+
readonly hash: string;
|
|
1539
|
+
readonly path: string;
|
|
1540
|
+
readonly bytes: number;
|
|
1541
|
+
readonly mtimeMs: number;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
const CANONICAL_BLOB_NAME = /^[0-9a-f]{64}$/;
|
|
1545
|
+
const BLOB_REFERENCE = /blob:sha256:([0-9a-f]{64})/g;
|
|
1546
|
+
|
|
1547
|
+
/** Longest possible `blob:sha256:<hash>` reference, used to bound chunked scans. */
|
|
1548
|
+
export const BLOB_REFERENCE_MAX_LENGTH = BLOB_PREFIX.length + 64;
|
|
1549
|
+
|
|
1550
|
+
/** Collect every `blob:sha256:<hash>` reference that appears in `text`. */
|
|
1551
|
+
export function collectBlobReferences(text: string, into: Set<string>): void {
|
|
1552
|
+
for (const match of text.matchAll(BLOB_REFERENCE)) into.add(match[1]!);
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
/**
|
|
1556
|
+
* List canonical blob files. Anything that is not a plain, owner-visible regular
|
|
1557
|
+
* file named after its own hash (temp files, symlinks, subdirectories) is
|
|
1558
|
+
* skipped: a sweep must never reason about entries it cannot classify.
|
|
1559
|
+
*/
|
|
1560
|
+
export async function listCanonicalBlobs(dir: string): Promise<CanonicalBlobEntry[]> {
|
|
1561
|
+
let names: string[];
|
|
1562
|
+
try {
|
|
1563
|
+
names = await fsp.readdir(dir);
|
|
1564
|
+
} catch (error) {
|
|
1565
|
+
if (isEnoent(error)) return [];
|
|
1566
|
+
throw error;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
const entries: CanonicalBlobEntry[] = [];
|
|
1570
|
+
for (const name of names) {
|
|
1571
|
+
if (!CANONICAL_BLOB_NAME.test(name)) continue;
|
|
1572
|
+
const blobPath = path.join(dir, name);
|
|
1573
|
+
let stat: fs.Stats;
|
|
1574
|
+
try {
|
|
1575
|
+
stat = await fsp.lstat(blobPath);
|
|
1576
|
+
} catch {
|
|
1577
|
+
continue;
|
|
1578
|
+
}
|
|
1579
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) continue;
|
|
1580
|
+
entries.push({ hash: name, path: blobPath, bytes: stat.size, mtimeMs: stat.mtimeMs });
|
|
1581
|
+
}
|
|
1582
|
+
return entries;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
/**
|
|
1586
|
+
* Remove one canonical blob, fail-closed on identity drift. The entry captured
|
|
1587
|
+
* at scan time must still describe the file at unlink time (same inode, size and
|
|
1588
|
+
* mtime, still a single-link regular file), otherwise the blob is left in place.
|
|
1589
|
+
*
|
|
1590
|
+
* `failed` distinguishes an actual IO failure (which a caller should surface as
|
|
1591
|
+
* a failed reclaim) from a revalidation refusal (which is an ordinary KEEP).
|
|
1592
|
+
*/
|
|
1593
|
+
export async function removeCanonicalBlob(
|
|
1594
|
+
entry: CanonicalBlobEntry,
|
|
1595
|
+
): Promise<{ removed: true } | { removed: false; reason: string; failed?: true }> {
|
|
1596
|
+
let stat: fs.Stats;
|
|
1597
|
+
try {
|
|
1598
|
+
stat = await fsp.lstat(entry.path);
|
|
1599
|
+
} catch (error) {
|
|
1600
|
+
if (isEnoent(error)) return { removed: false, reason: "blob_disappeared" };
|
|
1601
|
+
return { removed: false, reason: `blob_unverifiable: ${String(error)}`, failed: true };
|
|
1602
|
+
}
|
|
1603
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) {
|
|
1604
|
+
return { removed: false, reason: "blob_not_plain_file" };
|
|
1605
|
+
}
|
|
1606
|
+
if (stat.size !== entry.bytes || stat.mtimeMs !== entry.mtimeMs) return { removed: false, reason: "blob_changed" };
|
|
1607
|
+
try {
|
|
1608
|
+
await fsp.unlink(entry.path);
|
|
1609
|
+
return { removed: true };
|
|
1610
|
+
} catch (error) {
|
|
1611
|
+
if (isEnoent(error)) return { removed: false, reason: "blob_disappeared" };
|
|
1612
|
+
return { removed: false, reason: `blob_unlink_failed: ${String(error)}`, failed: true };
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
copyManagedFileNoReplace,
|
|
38
38
|
ensureManagedDirectory,
|
|
39
39
|
fsyncManagedArtifactTree,
|
|
40
|
+
inspectManagedFileNoFollow,
|
|
40
41
|
MANAGED_ARTIFACT_COPY_BATCH_SIZE,
|
|
41
42
|
MANAGED_ARTIFACT_MAX_FILES,
|
|
42
43
|
MANAGED_ARTIFACT_MAX_TOTAL_BYTES,
|
|
@@ -784,7 +785,7 @@ function matchesPreflightIdentity(candidate: ManagedCandidate, preflight: Candid
|
|
|
784
785
|
|
|
785
786
|
function inspectCandidate(filePath: string, provenance: "v2" | "legacy"): ManagedCandidate | { code: string } {
|
|
786
787
|
try {
|
|
787
|
-
const snapshot =
|
|
788
|
+
const snapshot = inspectManagedFileNoFollow(filePath, HEADER_MAX_BYTES);
|
|
788
789
|
const lineEnd = snapshot.bytes.subarray(0, HEADER_MAX_BYTES).indexOf(0x0a);
|
|
789
790
|
if (lineEnd < 0) return { code: "invalid_header" };
|
|
790
791
|
const value: unknown = JSON.parse(snapshot.bytes.subarray(0, lineEnd).toString("utf8"));
|
|
@@ -816,7 +817,7 @@ function inspectCandidate(filePath: string, provenance: "v2" | "legacy"): Manage
|
|
|
816
817
|
...snapshot.identity,
|
|
817
818
|
nlink: snapshot.identity.nlink,
|
|
818
819
|
mtimeMs: Number(named.mtimeMs),
|
|
819
|
-
sha256:
|
|
820
|
+
sha256: snapshot.identity.sha256,
|
|
820
821
|
},
|
|
821
822
|
};
|
|
822
823
|
} catch (error) {
|
|
@@ -3376,32 +3377,37 @@ function receiptPair(scope: ManagedScope, candidate: ManagedCandidate): ManagedC
|
|
|
3376
3377
|
const directory = path.join(managedInternalDirectory(scope), MANAGED_RECEIPTS_DIRECTORY);
|
|
3377
3378
|
try {
|
|
3378
3379
|
for (const name of fs.readdirSync(directory)) {
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3380
|
+
try {
|
|
3381
|
+
const pathname = path.join(directory, name);
|
|
3382
|
+
const value: unknown = JSON.parse(captureManagedFileNoFollow(pathname).bytes.toString("utf8"));
|
|
3383
|
+
if (!value || typeof value !== "object") continue;
|
|
3384
|
+
const record = value as { source?: { path?: unknown }; destination?: { path?: unknown } };
|
|
3385
|
+
const otherPath =
|
|
3386
|
+
record.source?.path === candidate.path
|
|
3387
|
+
? record.destination?.path
|
|
3388
|
+
: record.destination?.path === candidate.path
|
|
3389
|
+
? record.source?.path
|
|
3390
|
+
: undefined;
|
|
3391
|
+
if (typeof otherPath !== "string") continue;
|
|
3392
|
+
const other = inspectCandidate(otherPath, candidate.provenance === "v2" ? "legacy" : "v2");
|
|
3393
|
+
if (
|
|
3394
|
+
"code" in other ||
|
|
3395
|
+
!receiptMatches(
|
|
3396
|
+
pathname,
|
|
3397
|
+
candidate.provenance === "legacy" ? candidate : other,
|
|
3398
|
+
candidate.provenance === "v2" ? candidate : other,
|
|
3399
|
+
scope,
|
|
3400
|
+
)
|
|
3398
3401
|
)
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
+
continue;
|
|
3403
|
+
return other;
|
|
3404
|
+
} catch {
|
|
3405
|
+
// Native cleanup residues, symlinks, and malformed files grant no authority;
|
|
3406
|
+
// continue to the deterministic committed receipt instead of aborting the scan.
|
|
3407
|
+
}
|
|
3402
3408
|
}
|
|
3403
3409
|
} catch {
|
|
3404
|
-
/*
|
|
3410
|
+
/* a missing receipts directory grants no shadow authority */
|
|
3405
3411
|
}
|
|
3406
3412
|
return undefined;
|
|
3407
3413
|
}
|
|
@@ -3857,8 +3863,12 @@ async function openManagedCandidateForWriteInternal(
|
|
|
3857
3863
|
assertPublicationConsent,
|
|
3858
3864
|
scopeRoot(scope),
|
|
3859
3865
|
);
|
|
3866
|
+
assertPublicationConsent();
|
|
3860
3867
|
} catch (error) {
|
|
3861
|
-
if ((error as Error).message !== "destination_conflict")
|
|
3868
|
+
if ((error as Error).message !== "destination_conflict") {
|
|
3869
|
+
await removeStagedReceipts(scope, afterLock);
|
|
3870
|
+
throw error;
|
|
3871
|
+
}
|
|
3862
3872
|
}
|
|
3863
3873
|
|
|
3864
3874
|
revalidatePickerConsent(scope, afterLock, expectedIdentity);
|
|
@@ -2,14 +2,14 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as fsp from "node:fs/promises";
|
|
4
4
|
import * as path from "node:path";
|
|
5
|
-
|
|
6
5
|
import type {
|
|
7
6
|
NativeDirectoryTreeSnapshot,
|
|
8
7
|
NativeExactUnlinkResult,
|
|
9
8
|
NativeOwnerOnlySecurityResult,
|
|
9
|
+
RecoveryFsIdentity,
|
|
10
10
|
RecoveryFsRoot,
|
|
11
11
|
} from "@gajae-code/natives";
|
|
12
|
-
|
|
12
|
+
import type { SessionStorageStat } from "../session-storage";
|
|
13
13
|
import {
|
|
14
14
|
classifyNativePublishOutcome,
|
|
15
15
|
formatNativePublishDiagnostic,
|
|
@@ -208,6 +208,68 @@ export interface ManagedFileSnapshot {
|
|
|
208
208
|
sha256: string;
|
|
209
209
|
};
|
|
210
210
|
}
|
|
211
|
+
/**
|
|
212
|
+
* Descriptor identity of one managed file object. Deliberately excludes the
|
|
213
|
+
* content sha256: append receipts must bind the post-operation object without
|
|
214
|
+
* re-reading the whole payload.
|
|
215
|
+
*/
|
|
216
|
+
export interface ManagedFileIdentity {
|
|
217
|
+
dev: bigint;
|
|
218
|
+
ino: bigint;
|
|
219
|
+
nlink: bigint;
|
|
220
|
+
size: number;
|
|
221
|
+
mtimeNs: bigint;
|
|
222
|
+
ctimeNs: bigint;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Post-operation evidence for one managed append. `descriptor` is the
|
|
227
|
+
* `SessionStorageStat`-shaped snapshot of the retained/successor object captured
|
|
228
|
+
* AFTER the append has been synchronized (never a pathname `statSync`); `identity`
|
|
229
|
+
* carries the same fields as the object identity. The SessionManager feeds
|
|
230
|
+
* `descriptor` directly into the commit-marker snapshot.
|
|
231
|
+
*/
|
|
232
|
+
export interface ManagedAppendReceipt {
|
|
233
|
+
descriptor: SessionStorageStat;
|
|
234
|
+
identity: ManagedFileIdentity;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export interface ManagedBoundedAppendExpectation {
|
|
238
|
+
readonly dev: string;
|
|
239
|
+
readonly ino: string;
|
|
240
|
+
readonly size: string;
|
|
241
|
+
readonly mtimeNs: string;
|
|
242
|
+
readonly ctimeNs: string;
|
|
243
|
+
readonly sha256: string;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function managedFileIdentityFromNative(identity: RecoveryFsIdentity): ManagedFileIdentity {
|
|
247
|
+
return {
|
|
248
|
+
dev: BigInt(identity.dev),
|
|
249
|
+
ino: BigInt(identity.ino),
|
|
250
|
+
nlink: BigInt(identity.nlink),
|
|
251
|
+
size: Number(identity.size),
|
|
252
|
+
mtimeNs: BigInt(identity.mtimeNs),
|
|
253
|
+
ctimeNs: BigInt(identity.ctimeNs),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function managedAppendReceiptFromIdentity(identity: ManagedFileIdentity): ManagedAppendReceipt {
|
|
258
|
+
return {
|
|
259
|
+
identity,
|
|
260
|
+
descriptor: {
|
|
261
|
+
dev: identity.dev,
|
|
262
|
+
ino: identity.ino,
|
|
263
|
+
nlink: identity.nlink,
|
|
264
|
+
size: identity.size,
|
|
265
|
+
mtimeMs: Number(identity.mtimeNs) / 1_000_000,
|
|
266
|
+
mtimeNs: identity.mtimeNs,
|
|
267
|
+
ctimeNs: identity.ctimeNs,
|
|
268
|
+
mtime: new Date(Number(identity.mtimeNs) / 1_000_000),
|
|
269
|
+
isFile: true,
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
211
273
|
|
|
212
274
|
type SerializedReplacementIdentity = {
|
|
213
275
|
dev: string;
|
|
@@ -1207,40 +1269,90 @@ export class ManagedSessionDescendantStore {
|
|
|
1207
1269
|
this.#assertBound();
|
|
1208
1270
|
}
|
|
1209
1271
|
|
|
1210
|
-
|
|
1272
|
+
captureBoundedAppendExpectation(relativePath: string): ManagedBoundedAppendExpectation | undefined {
|
|
1273
|
+
this.#assertBound();
|
|
1274
|
+
if (!this.#authority) return undefined;
|
|
1275
|
+
const observed = this.#authority.stat(this.#relative(this.#resolve(relativePath)));
|
|
1276
|
+
if (!observed.ok || !observed.identity) return undefined;
|
|
1277
|
+
const sha256 = observed.identity.sha256;
|
|
1278
|
+
if (typeof sha256 !== "string" || !/^[0-9a-f]{64}$/i.test(sha256)) return undefined;
|
|
1279
|
+
return {
|
|
1280
|
+
dev: observed.identity.dev,
|
|
1281
|
+
ino: observed.identity.ino,
|
|
1282
|
+
size: observed.identity.size,
|
|
1283
|
+
mtimeNs: observed.identity.mtimeNs,
|
|
1284
|
+
ctimeNs: observed.identity.ctimeNs,
|
|
1285
|
+
sha256: sha256.toLowerCase(),
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
appendExpectedSync(
|
|
1290
|
+
relativePath: string,
|
|
1291
|
+
bytes: Uint8Array,
|
|
1292
|
+
expected: ManagedBoundedAppendExpectation,
|
|
1293
|
+
): ManagedAppendReceipt {
|
|
1294
|
+
this.#beforeMutation();
|
|
1295
|
+
this.#assertBound();
|
|
1296
|
+
if (!this.#authority) throw new Error("managed_bounded_append_unavailable");
|
|
1297
|
+
const relative = this.#relative(this.#resolve(relativePath));
|
|
1298
|
+
const appended = this.#authority.appendManaged(
|
|
1299
|
+
relative,
|
|
1300
|
+
bytes,
|
|
1301
|
+
expected.dev,
|
|
1302
|
+
expected.ino,
|
|
1303
|
+
expected.size,
|
|
1304
|
+
expected.mtimeNs,
|
|
1305
|
+
expected.ctimeNs,
|
|
1306
|
+
expected.sha256,
|
|
1307
|
+
);
|
|
1308
|
+
if (!appended.ok) throw new Error(appended.code ?? "managed_append_failed");
|
|
1309
|
+
const stat = this.#authority.stat(relative);
|
|
1310
|
+
if (!stat.ok || !stat.identity) throw new Error(stat.code ?? "managed_append_identity_unavailable");
|
|
1311
|
+
this.#assertBound();
|
|
1312
|
+
return managedAppendReceiptFromIdentity(managedFileIdentityFromNative(stat.identity));
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
appendSync(relativePath: string, bytes: Uint8Array): ManagedAppendReceipt {
|
|
1211
1316
|
this.#beforeMutation();
|
|
1212
1317
|
this.#assertBound();
|
|
1213
1318
|
const resolved = this.#resolve(relativePath);
|
|
1214
|
-
const existing = this.readExpected(relativePath);
|
|
1215
|
-
if (!existing) throw new Error("managed_append_missing");
|
|
1216
1319
|
if (this.#authority) {
|
|
1320
|
+
const relative = this.#relative(resolved);
|
|
1321
|
+
const observed = this.#authority.stat(relative);
|
|
1322
|
+
if (!observed.ok || !observed.identity) throw new Error(observed.code ?? "managed_append_missing");
|
|
1323
|
+
const nativeSha256 = observed.identity.sha256;
|
|
1324
|
+
const expectedSha256 =
|
|
1325
|
+
typeof nativeSha256 === "string" && /^[0-9a-f]{64}$/i.test(nativeSha256)
|
|
1326
|
+
? nativeSha256.toLowerCase()
|
|
1327
|
+
: this.readExpected(relativePath)?.identity.sha256;
|
|
1328
|
+
if (!expectedSha256) throw new Error("managed_append_missing");
|
|
1217
1329
|
const appended = this.#authority.appendManaged(
|
|
1218
|
-
|
|
1330
|
+
relative,
|
|
1219
1331
|
bytes,
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1332
|
+
observed.identity.dev,
|
|
1333
|
+
observed.identity.ino,
|
|
1334
|
+
observed.identity.size,
|
|
1335
|
+
observed.identity.mtimeNs,
|
|
1336
|
+
observed.identity.ctimeNs,
|
|
1337
|
+
expectedSha256,
|
|
1226
1338
|
);
|
|
1227
1339
|
if (!appended.ok) throw new Error(appended.code ?? "managed_append_failed");
|
|
1340
|
+
// Post-fsync descriptor capture through retained authority: the native append
|
|
1341
|
+
// is synchronous and durable, so the stat reflects the committed object.
|
|
1342
|
+
const stat = this.#authority.stat(relative);
|
|
1343
|
+
if (!stat.ok || !stat.identity) throw new Error(stat.code ?? "managed_append_identity_unavailable");
|
|
1344
|
+
const receipt = managedAppendReceiptFromIdentity(managedFileIdentityFromNative(stat.identity));
|
|
1228
1345
|
this.#assertBound();
|
|
1229
|
-
return;
|
|
1346
|
+
return receipt;
|
|
1230
1347
|
}
|
|
1231
|
-
// Darwin has no retained native root authority.
|
|
1232
|
-
//
|
|
1233
|
-
//
|
|
1234
|
-
//
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
Buffer.concat([existing.bytes, Buffer.from(bytes)]),
|
|
1238
|
-
this.#subtreeRoot,
|
|
1239
|
-
this.#policy,
|
|
1240
|
-
undefined,
|
|
1241
|
-
existing.identity,
|
|
1242
|
-
);
|
|
1348
|
+
// Darwin has no retained native root authority. Copy the predecessor through a
|
|
1349
|
+
// fixed 64 KiB buffer into an exact-replacement staging file, append the new
|
|
1350
|
+
// record bytes, and publish through the existing cleanup-receipt protocol.
|
|
1351
|
+
// The transcript-linear disk pass is unavoidable; resident memory is bounded.
|
|
1352
|
+
const successor = appendManagedFileStreamingSync(resolved, bytes, this.#subtreeRoot, this.#policy);
|
|
1353
|
+
const receipt = managedAppendReceiptFromIdentity(successor);
|
|
1243
1354
|
this.#assertBound();
|
|
1355
|
+
return receipt;
|
|
1244
1356
|
}
|
|
1245
1357
|
|
|
1246
1358
|
#relative(resolved: string): string {
|
|
@@ -1349,6 +1461,23 @@ export class ManagedSessionDescendantStore {
|
|
|
1349
1461
|
if (!replaced.ok) throw new Error(replaced.code ?? "managed_replace_failed");
|
|
1350
1462
|
}
|
|
1351
1463
|
|
|
1464
|
+
/** Capture descriptor identity without copying file bytes when retained authority is available. */
|
|
1465
|
+
descriptorExpected(relativePath: string): SessionStorageStat | null {
|
|
1466
|
+
this.#assertBound();
|
|
1467
|
+
const resolved = this.#resolve(relativePath);
|
|
1468
|
+
if (!this.#authority) {
|
|
1469
|
+
const snapshot = this.readExpected(relativePath);
|
|
1470
|
+
return snapshot ? managedAppendReceiptFromIdentity(snapshot.identity).descriptor : null;
|
|
1471
|
+
}
|
|
1472
|
+
const stat = this.#authority.stat(this.#relative(resolved));
|
|
1473
|
+
if (!stat.ok) {
|
|
1474
|
+
if (stat.code === "not_found") return null;
|
|
1475
|
+
throw new Error(stat.code ?? "managed_stat_failed");
|
|
1476
|
+
}
|
|
1477
|
+
if (!stat.identity) throw new Error("managed_stat_identity_unavailable");
|
|
1478
|
+
return managedAppendReceiptFromIdentity(managedFileIdentityFromNative(stat.identity)).descriptor;
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1352
1481
|
/** Read an exact managed file without exposing its pathname as authority. */
|
|
1353
1482
|
readExpected(relativePath: string): ManagedFileSnapshot | null {
|
|
1354
1483
|
this.#assertBound();
|
|
@@ -1905,6 +2034,62 @@ function captureManagedFileNoFollowLimit(pathname: string, maxBytes?: number): M
|
|
|
1905
2034
|
}
|
|
1906
2035
|
}
|
|
1907
2036
|
|
|
2037
|
+
/** Streams a managed file once while retaining only a bounded header prefix and the full descriptor-bound digest. */
|
|
2038
|
+
export function inspectManagedFileNoFollow(pathname: string, prefixLimit: number): ManagedFileSnapshot {
|
|
2039
|
+
if (!Number.isSafeInteger(prefixLimit) || prefixLimit < 0) throw new Error("invalid_capture_limit");
|
|
2040
|
+
const fd = fs.openSync(pathname, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
2041
|
+
try {
|
|
2042
|
+
const before = fs.fstatSync(fd, { bigint: true });
|
|
2043
|
+
if (!before.isFile() || before.nlink > 1) throw new Error("source_changed");
|
|
2044
|
+
const prefix = Buffer.alloc(Math.min(Number(before.size), prefixLimit));
|
|
2045
|
+
const hash = createHash("sha256");
|
|
2046
|
+
const chunk = Buffer.alloc(64 * 1024);
|
|
2047
|
+
let offset = 0;
|
|
2048
|
+
for (;;) {
|
|
2049
|
+
const count = fs.readSync(fd, chunk, 0, chunk.byteLength, null);
|
|
2050
|
+
if (count === 0) break;
|
|
2051
|
+
hash.update(chunk.subarray(0, count));
|
|
2052
|
+
if (offset < prefix.byteLength) {
|
|
2053
|
+
const copied = Math.min(count, prefix.byteLength - offset);
|
|
2054
|
+
chunk.copy(prefix, offset, 0, copied);
|
|
2055
|
+
}
|
|
2056
|
+
offset += count;
|
|
2057
|
+
}
|
|
2058
|
+
if (offset !== Number(before.size)) throw new Error("source_changed");
|
|
2059
|
+
const after = fs.fstatSync(fd, { bigint: true });
|
|
2060
|
+
if (!sameIdentity(identity(before), identity(after))) throw new Error("source_changed");
|
|
2061
|
+
const named = fs.lstatSync(pathname, { bigint: true });
|
|
2062
|
+
if (!named.isFile() || named.isSymbolicLink() || !sameIdentity(identity(before), identity(named)))
|
|
2063
|
+
throw new Error("source_changed");
|
|
2064
|
+
return { bytes: prefix, identity: identity(before, hash.digest("hex")) };
|
|
2065
|
+
} finally {
|
|
2066
|
+
fs.closeSync(fd);
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
function captureManagedFileIdentityStreamingNoFollow(pathname: string): ManagedFileSnapshot["identity"] {
|
|
2071
|
+
const fd = fs.openSync(pathname, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
2072
|
+
try {
|
|
2073
|
+
const before = fs.fstatSync(fd, { bigint: true });
|
|
2074
|
+
if (!before.isFile() || before.nlink > 1) throw new Error("source_changed");
|
|
2075
|
+
const hash = createHash("sha256");
|
|
2076
|
+
const chunk = Buffer.alloc(64 * 1024);
|
|
2077
|
+
for (;;) {
|
|
2078
|
+
const count = fs.readSync(fd, chunk, 0, chunk.byteLength, null);
|
|
2079
|
+
if (count === 0) break;
|
|
2080
|
+
hash.update(chunk.subarray(0, count));
|
|
2081
|
+
}
|
|
2082
|
+
const after = fs.fstatSync(fd, { bigint: true });
|
|
2083
|
+
if (!sameIdentity(identity(before), identity(after))) throw new Error("source_changed");
|
|
2084
|
+
const named = fs.lstatSync(pathname, { bigint: true });
|
|
2085
|
+
if (!named.isFile() || named.isSymbolicLink() || !sameIdentity(identity(before), identity(named)))
|
|
2086
|
+
throw new Error("source_changed");
|
|
2087
|
+
return identity(before, hash.digest("hex"));
|
|
2088
|
+
} finally {
|
|
2089
|
+
fs.closeSync(fd);
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
|
|
1908
2093
|
/** Atomically publishes bytes without replacing an existing destination. */
|
|
1909
2094
|
export async function publishManagedFileNoReplace(
|
|
1910
2095
|
destination: string,
|
|
@@ -2058,9 +2243,9 @@ export function publishManagedFileNoReplaceSync(
|
|
|
2058
2243
|
}
|
|
2059
2244
|
|
|
2060
2245
|
/** Replace one managed regular file while retaining the secured staging fd through publication. */
|
|
2061
|
-
|
|
2246
|
+
function replaceManagedFileGeneratedSync(
|
|
2062
2247
|
destination: string,
|
|
2063
|
-
|
|
2248
|
+
writeContent: (fd: number) => { size: number; sha256: string },
|
|
2064
2249
|
root: ManagedDirectoryRoot,
|
|
2065
2250
|
policy: ManagedSessionSecurityPolicy = "default",
|
|
2066
2251
|
assertFence?: () => void,
|
|
@@ -2089,8 +2274,7 @@ export function replaceManagedFileSync(
|
|
|
2089
2274
|
0o600,
|
|
2090
2275
|
);
|
|
2091
2276
|
secureFileDescriptor(staging, fd, "apply");
|
|
2092
|
-
|
|
2093
|
-
while (offset < bytes.byteLength) offset += fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
|
|
2277
|
+
const generated = writeContent(fd);
|
|
2094
2278
|
fs.fsyncSync(fd);
|
|
2095
2279
|
secureFileDescriptor(staging, fd, "verify");
|
|
2096
2280
|
const staged = fs.fstatSync(fd, { bigint: true });
|
|
@@ -2103,7 +2287,8 @@ export function replaceManagedFileSync(
|
|
|
2103
2287
|
assertFence?.();
|
|
2104
2288
|
if (expectedDestination) {
|
|
2105
2289
|
const parentIdentity = fs.lstatSync(parent, { bigint: true });
|
|
2106
|
-
|
|
2290
|
+
if (Number(staged.size) !== generated.size) throw new Error("managed_replace_generated_size_changed");
|
|
2291
|
+
const successor = identity(staged, generated.sha256);
|
|
2107
2292
|
const receiptStagingPath = path.join(parent, `.gjc-replace-receipt-pending-${randomUUID()}.json`);
|
|
2108
2293
|
preserveStaging = true;
|
|
2109
2294
|
const publishedReceiptIdentity = publishManagedFileNoReplaceSync(
|
|
@@ -2164,8 +2349,8 @@ export function replaceManagedFileSync(
|
|
|
2164
2349
|
},
|
|
2165
2350
|
);
|
|
2166
2351
|
if (!replaced.ok) throw new ManagedReplaceError(replaced, receiptCleanup.path);
|
|
2167
|
-
const named =
|
|
2168
|
-
if (!sameReplacementIdentity(named
|
|
2352
|
+
const named = captureManagedFileIdentityStreamingNoFollow(destination);
|
|
2353
|
+
if (!sameReplacementIdentity(named, successor) || named.sha256 !== successor.sha256)
|
|
2169
2354
|
throw new Error("destination_identity_changed");
|
|
2170
2355
|
fsyncDirectory(parent);
|
|
2171
2356
|
} else fs.renameSync(staging, destination);
|
|
@@ -2229,6 +2414,92 @@ export function replaceManagedFileSync(
|
|
|
2229
2414
|
if (failure !== undefined) throw failure;
|
|
2230
2415
|
}
|
|
2231
2416
|
|
|
2417
|
+
export function replaceManagedFileSync(
|
|
2418
|
+
destination: string,
|
|
2419
|
+
bytes: Uint8Array,
|
|
2420
|
+
root: ManagedDirectoryRoot,
|
|
2421
|
+
policy: ManagedSessionSecurityPolicy = "default",
|
|
2422
|
+
assertFence?: () => void,
|
|
2423
|
+
expectedDestination?: ManagedFileSnapshot["identity"],
|
|
2424
|
+
): void {
|
|
2425
|
+
replaceManagedFileGeneratedSync(
|
|
2426
|
+
destination,
|
|
2427
|
+
fd => {
|
|
2428
|
+
const hash = createHash("sha256");
|
|
2429
|
+
let offset = 0;
|
|
2430
|
+
while (offset < bytes.byteLength) {
|
|
2431
|
+
const written = fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
|
|
2432
|
+
if (written === 0) throw new Error("managed_replace_short_write");
|
|
2433
|
+
hash.update(bytes.subarray(offset, offset + written));
|
|
2434
|
+
offset += written;
|
|
2435
|
+
}
|
|
2436
|
+
return { size: offset, sha256: hash.digest("hex") };
|
|
2437
|
+
},
|
|
2438
|
+
root,
|
|
2439
|
+
policy,
|
|
2440
|
+
assertFence,
|
|
2441
|
+
expectedDestination,
|
|
2442
|
+
);
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
function appendManagedFileStreamingSync(
|
|
2446
|
+
destination: string,
|
|
2447
|
+
appendedBytes: Uint8Array,
|
|
2448
|
+
root: ManagedDirectoryRoot,
|
|
2449
|
+
policy: ManagedSessionSecurityPolicy,
|
|
2450
|
+
): ManagedFileSnapshot["identity"] {
|
|
2451
|
+
const predecessor = captureManagedFileIdentityStreamingNoFollow(destination);
|
|
2452
|
+
replaceManagedFileGeneratedSync(
|
|
2453
|
+
destination,
|
|
2454
|
+
stagingFd => {
|
|
2455
|
+
const sourceFd = fs.openSync(destination, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
2456
|
+
try {
|
|
2457
|
+
const before = fs.fstatSync(sourceFd, { bigint: true });
|
|
2458
|
+
if (!sameReplacementIdentity(identity(before, predecessor.sha256), predecessor))
|
|
2459
|
+
throw new Error("managed_replace_identity_mismatch");
|
|
2460
|
+
const hash = createHash("sha256");
|
|
2461
|
+
const chunk = Buffer.alloc(64 * 1024);
|
|
2462
|
+
let total = 0;
|
|
2463
|
+
for (;;) {
|
|
2464
|
+
const count = fs.readSync(sourceFd, chunk, 0, chunk.byteLength, null);
|
|
2465
|
+
if (count === 0) break;
|
|
2466
|
+
let written = 0;
|
|
2467
|
+
while (written < count) {
|
|
2468
|
+
const amount = fs.writeSync(stagingFd, chunk, written, count - written);
|
|
2469
|
+
if (amount === 0) throw new Error("managed_replace_short_write");
|
|
2470
|
+
written += amount;
|
|
2471
|
+
}
|
|
2472
|
+
hash.update(chunk.subarray(0, count));
|
|
2473
|
+
total += count;
|
|
2474
|
+
}
|
|
2475
|
+
const after = fs.fstatSync(sourceFd, { bigint: true });
|
|
2476
|
+
const named = fs.lstatSync(destination, { bigint: true });
|
|
2477
|
+
if (
|
|
2478
|
+
!sameReplacementIdentity(identity(after, predecessor.sha256), predecessor) ||
|
|
2479
|
+
!sameReplacementIdentity(identity(named, predecessor.sha256), predecessor)
|
|
2480
|
+
)
|
|
2481
|
+
throw new Error("managed_replace_identity_mismatch");
|
|
2482
|
+
if (hash.copy().digest("hex") !== predecessor.sha256) throw new Error("managed_replace_identity_mismatch");
|
|
2483
|
+
let appended = 0;
|
|
2484
|
+
while (appended < appendedBytes.byteLength) {
|
|
2485
|
+
const amount = fs.writeSync(stagingFd, appendedBytes, appended, appendedBytes.byteLength - appended);
|
|
2486
|
+
if (amount === 0) throw new Error("managed_replace_short_write");
|
|
2487
|
+
hash.update(appendedBytes.subarray(appended, appended + amount));
|
|
2488
|
+
appended += amount;
|
|
2489
|
+
}
|
|
2490
|
+
return { size: total + appended, sha256: hash.digest("hex") };
|
|
2491
|
+
} finally {
|
|
2492
|
+
fs.closeSync(sourceFd);
|
|
2493
|
+
}
|
|
2494
|
+
},
|
|
2495
|
+
root,
|
|
2496
|
+
policy,
|
|
2497
|
+
undefined,
|
|
2498
|
+
predecessor,
|
|
2499
|
+
);
|
|
2500
|
+
return captureManagedFileIdentityStreamingNoFollow(destination);
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2232
2503
|
export async function replaceManagedFile(
|
|
2233
2504
|
destination: string,
|
|
2234
2505
|
bytes: Uint8Array,
|