@animalabs/agent-framework 0.11.0 → 0.13.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/dist/src/agent.d.ts +11 -1
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +60 -4
- package/dist/src/agent.js.map +1 -1
- package/dist/src/framework.d.ts +72 -1
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +873 -123
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +7 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +15 -1
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/kv-unified-wire.d.ts +16 -0
- package/dist/src/kv-unified-wire.d.ts.map +1 -0
- package/dist/src/kv-unified-wire.js +2 -0
- package/dist/src/kv-unified-wire.js.map +1 -0
- package/dist/src/mcpl/channel-registry.d.ts +84 -1
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +188 -5
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
- package/dist/src/mcpl/hook-orchestrator.js +28 -2
- package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts +6 -1
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +29 -2
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/reference-fetcher.d.ts +59 -0
- package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -0
- package/dist/src/mcpl/reference-fetcher.js +240 -0
- package/dist/src/mcpl/reference-fetcher.js.map +1 -0
- package/dist/src/mcpl/references.d.ts +125 -0
- package/dist/src/mcpl/references.d.ts.map +1 -0
- package/dist/src/mcpl/references.js +262 -0
- package/dist/src/mcpl/references.js.map +1 -0
- package/dist/src/mcpl/types.d.ts +32 -0
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/modules/index.d.ts +2 -1
- package/dist/src/modules/index.d.ts.map +1 -1
- package/dist/src/modules/index.js +1 -1
- package/dist/src/modules/index.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +125 -2
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +354 -58
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +7 -0
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +14 -0
- package/dist/src/tool-result-history.js.map +1 -1
- package/dist/src/tool-wrapper-prose-guard.d.ts +8 -0
- package/dist/src/tool-wrapper-prose-guard.d.ts.map +1 -0
- package/dist/src/tool-wrapper-prose-guard.js +37 -0
- package/dist/src/tool-wrapper-prose-guard.js.map +1 -0
- package/dist/src/tune-out/coordinator.d.ts +144 -0
- package/dist/src/tune-out/coordinator.d.ts.map +1 -0
- package/dist/src/tune-out/coordinator.js +444 -0
- package/dist/src/tune-out/coordinator.js.map +1 -0
- package/dist/src/tune-out/tools.d.ts +43 -0
- package/dist/src/tune-out/tools.d.ts.map +1 -0
- package/dist/src/tune-out/tools.js +93 -0
- package/dist/src/tune-out/tools.js.map +1 -0
- package/dist/src/types/agent.d.ts +15 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +6 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/usage-accounting.d.ts +5 -0
- package/dist/src/usage-accounting.d.ts.map +1 -0
- package/dist/src/usage-accounting.js +11 -0
- package/dist/src/usage-accounting.js.map +1 -0
- package/dist/test/channel-incoming-targeting.test.d.ts +2 -0
- package/dist/test/channel-incoming-targeting.test.d.ts.map +1 -0
- package/dist/test/channel-incoming-targeting.test.js +81 -0
- package/dist/test/channel-incoming-targeting.test.js.map +1 -0
- package/dist/test/conversation-routing.test.js +39 -0
- package/dist/test/conversation-routing.test.js.map +1 -1
- package/dist/test/kv-unified-wiring.test.d.ts +2 -0
- package/dist/test/kv-unified-wiring.test.d.ts.map +1 -0
- package/dist/test/kv-unified-wiring.test.js +56 -0
- package/dist/test/kv-unified-wiring.test.js.map +1 -0
- package/dist/test/mcpl-push-convert.test.d.ts +2 -0
- package/dist/test/mcpl-push-convert.test.d.ts.map +1 -0
- package/dist/test/mcpl-push-convert.test.js +42 -0
- package/dist/test/mcpl-push-convert.test.js.map +1 -0
- package/dist/test/mcpl-reference-fetcher.test.d.ts +2 -0
- package/dist/test/mcpl-reference-fetcher.test.d.ts.map +1 -0
- package/dist/test/mcpl-reference-fetcher.test.js +199 -0
- package/dist/test/mcpl-reference-fetcher.test.js.map +1 -0
- package/dist/test/per-agent-delivery.test.d.ts +2 -0
- package/dist/test/per-agent-delivery.test.d.ts.map +1 -0
- package/dist/test/per-agent-delivery.test.js +108 -0
- package/dist/test/per-agent-delivery.test.js.map +1 -0
- package/dist/test/present-while-acting.test.js +64 -0
- package/dist/test/present-while-acting.test.js.map +1 -1
- package/dist/test/tool-result-history.test.js +78 -0
- package/dist/test/tool-result-history.test.js.map +1 -1
- package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts +2 -0
- package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts.map +1 -0
- package/dist/test/tool-wrapper-prose-guard-integration.test.js +485 -0
- package/dist/test/tool-wrapper-prose-guard-integration.test.js.map +1 -0
- package/dist/test/tool-wrapper-prose-guard.test.d.ts +2 -0
- package/dist/test/tool-wrapper-prose-guard.test.d.ts.map +1 -0
- package/dist/test/tool-wrapper-prose-guard.test.js +34 -0
- package/dist/test/tool-wrapper-prose-guard.test.js.map +1 -0
- package/dist/test/tune-out-e2e.test.d.ts +2 -0
- package/dist/test/tune-out-e2e.test.d.ts.map +1 -0
- package/dist/test/tune-out-e2e.test.js +142 -0
- package/dist/test/tune-out-e2e.test.js.map +1 -0
- package/dist/test/tune-out-gate-composition.test.d.ts +2 -0
- package/dist/test/tune-out-gate-composition.test.d.ts.map +1 -0
- package/dist/test/tune-out-gate-composition.test.js +221 -0
- package/dist/test/tune-out-gate-composition.test.js.map +1 -0
- package/dist/test/tune-out-lifecycle.test.d.ts +2 -0
- package/dist/test/tune-out-lifecycle.test.d.ts.map +1 -0
- package/dist/test/tune-out-lifecycle.test.js +121 -0
- package/dist/test/tune-out-lifecycle.test.js.map +1 -0
- package/dist/test/turn-trigger-provenance.test.d.ts +2 -0
- package/dist/test/turn-trigger-provenance.test.d.ts.map +1 -0
- package/dist/test/turn-trigger-provenance.test.js +85 -0
- package/dist/test/turn-trigger-provenance.test.js.map +1 -0
- package/dist/test/usage-accounting.test.d.ts +2 -0
- package/dist/test/usage-accounting.test.d.ts.map +1 -0
- package/dist/test/usage-accounting.test.js +13 -0
- package/dist/test/usage-accounting.test.js.map +1 -0
- package/dist/test/workspace-branch-continuation.test.d.ts +15 -0
- package/dist/test/workspace-branch-continuation.test.d.ts.map +1 -0
- package/dist/test/workspace-branch-continuation.test.js +183 -0
- package/dist/test/workspace-branch-continuation.test.js.map +1 -0
- package/dist/test/workspace-read-file-from-disk.test.d.ts +17 -0
- package/dist/test/workspace-read-file-from-disk.test.d.ts.map +1 -0
- package/dist/test/workspace-read-file-from-disk.test.js +174 -0
- package/dist/test/workspace-read-file-from-disk.test.js.map +1 -0
- package/dist/test/workspace-read-image.test.js +8 -0
- package/dist/test/workspace-read-image.test.js.map +1 -1
- package/dist/test/workspace-restore-order.test.js +1 -1
- package/dist/test/workspace-restore-order.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/agent.ts +77 -4
- package/src/framework.ts +953 -128
- package/src/gate/event-gate.ts +28 -3
- package/src/kv-unified-wire.ts +12 -0
- package/src/mcpl/channel-registry.ts +264 -8
- package/src/mcpl/hook-orchestrator.ts +29 -2
- package/src/mcpl/push-handler.ts +30 -2
- package/src/mcpl/reference-fetcher.ts +240 -0
- package/src/mcpl/references.ts +292 -0
- package/src/mcpl/types.ts +29 -0
- package/src/modules/index.ts +2 -1
- package/src/modules/workspace/index.ts +425 -66
- package/src/modules/workspace/types.ts +7 -0
- package/src/tool-result-history.ts +11 -0
- package/src/tool-wrapper-prose-guard.ts +38 -0
- package/src/tune-out/coordinator.ts +548 -0
- package/src/tune-out/tools.ts +129 -0
- package/src/types/agent.ts +16 -0
- package/src/types/framework.ts +7 -0
- package/src/usage-accounting.ts +8 -0
|
@@ -18,6 +18,30 @@ class WorkspaceImageReadError extends Error {
|
|
|
18
18
|
this.name = 'WorkspaceImageReadError';
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Thrown by `WorkspaceModule.readFileFromDisk()`. Messages carry the
|
|
23
|
+
* mount-prefixed path only — never absolute filesystem paths — so they can be
|
|
24
|
+
* surfaced to the agent or logged without leaking host layout.
|
|
25
|
+
*/
|
|
26
|
+
export class WorkspaceReadError extends Error {
|
|
27
|
+
code;
|
|
28
|
+
stage;
|
|
29
|
+
errno;
|
|
30
|
+
constructor(code, message, stage,
|
|
31
|
+
/** Underlying errno code (e.g. 'ENOENT', 'ELOOP') when a syscall failed. */
|
|
32
|
+
errno) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.code = code;
|
|
35
|
+
this.stage = stage;
|
|
36
|
+
this.errno = errno;
|
|
37
|
+
this.name = 'WorkspaceReadError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function errnoOf(err) {
|
|
41
|
+
return typeof err === 'object' && err !== null && 'code' in err && typeof err.code === 'string'
|
|
42
|
+
? err.code
|
|
43
|
+
: undefined;
|
|
44
|
+
}
|
|
21
45
|
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
22
46
|
const JPEG_SIGNATURE = Buffer.from([0xff, 0xd8, 0xff]);
|
|
23
47
|
const GIF87A_SIGNATURE = Buffer.from('GIF87a', 'ascii');
|
|
@@ -204,9 +228,13 @@ function validateJpeg(bytes, mountPrefixedPath) {
|
|
|
204
228
|
const marker = bytes[offset];
|
|
205
229
|
offset += 1;
|
|
206
230
|
if (marker === JPEG_EOI) {
|
|
207
|
-
if (!sawSof || !sawSos
|
|
231
|
+
if (!sawSof || !sawSos) {
|
|
208
232
|
invalidImage('JPEG', mountPrefixedPath);
|
|
209
233
|
}
|
|
234
|
+
// Bytes after EOI are tolerated: hardware encoders (e.g. Raspberry Pi
|
|
235
|
+
// camera stills) pad each frame to a 4-byte boundary with NULs after
|
|
236
|
+
// the EOI marker, and every mainstream decoder stops at EOI. The image
|
|
237
|
+
// proper (SOI..EOI) has been fully validated by this point.
|
|
210
238
|
return;
|
|
211
239
|
}
|
|
212
240
|
if (marker === JPEG_TEM || (marker >= 0xd0 && marker <= 0xd7)) {
|
|
@@ -516,6 +544,89 @@ export class WorkspaceModule {
|
|
|
516
544
|
}
|
|
517
545
|
}
|
|
518
546
|
this.savedState = null;
|
|
547
|
+
// Absorb out-of-band branch switches (e.g. an offline repair that left
|
|
548
|
+
// the store on a child branch): if the pinned branch is a strict ancestor
|
|
549
|
+
// of the current branch and disk state is fully contained in the current
|
|
550
|
+
// branch's history, re-pin. Without this, every materialize refuses
|
|
551
|
+
// forever after a repair, even though nothing on disk can be clobbered.
|
|
552
|
+
this.healBranchPins();
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* True when everything last materialized to disk for a mount is part of the
|
|
556
|
+
* CURRENT branch's history — i.e. the current branch is a linear
|
|
557
|
+
* continuation of the pinned branch as of the pinned sequence.
|
|
558
|
+
*
|
|
559
|
+
* Walks the current branch's parent chain. Safe iff the pinned branch is on
|
|
560
|
+
* the chain AND every fork point along the way is at or after `pinnedSeq`
|
|
561
|
+
* (a fork before `pinnedSeq` means disk holds records the current branch
|
|
562
|
+
* never had — genuine divergence). A missing/GC'd intermediate branch or a
|
|
563
|
+
* child without fork metadata is treated as unsafe: ancestry can't be
|
|
564
|
+
* proven, so the guard stays closed and `force` is the escape hatch.
|
|
565
|
+
*/
|
|
566
|
+
isLinearContinuation(store, pinnedBranchId, pinnedSeq) {
|
|
567
|
+
const current = store.currentBranch();
|
|
568
|
+
if (current.id === pinnedBranchId)
|
|
569
|
+
return true;
|
|
570
|
+
const byId = new Map(store.listBranches().map((b) => [b.id, b]));
|
|
571
|
+
const visited = new Set();
|
|
572
|
+
let cursor = byId.get(current.id);
|
|
573
|
+
while (cursor && !visited.has(cursor.id)) {
|
|
574
|
+
visited.add(cursor.id);
|
|
575
|
+
if (cursor.id === pinnedBranchId)
|
|
576
|
+
return true;
|
|
577
|
+
if (cursor.parentId === undefined || cursor.branchPoint === undefined)
|
|
578
|
+
return false;
|
|
579
|
+
if (cursor.branchPoint < pinnedSeq)
|
|
580
|
+
return false;
|
|
581
|
+
cursor = byId.get(cursor.parentId);
|
|
582
|
+
}
|
|
583
|
+
return false;
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Why materializing this mount is blocked on the current branch, or null if
|
|
587
|
+
* it isn't. Single source of truth for the materialize guard AND the
|
|
588
|
+
* `canMaterialize` status field, so status can never report `true` for a
|
|
589
|
+
* mount that materialize would refuse (the pre-fix defect: status computed
|
|
590
|
+
* per-mount id-equality while the guard checked ALL mounts).
|
|
591
|
+
*/
|
|
592
|
+
mountMaterializeBlockReason(store, mount) {
|
|
593
|
+
if (this.config.materializeOnlyActiveBranch === false)
|
|
594
|
+
return null;
|
|
595
|
+
if (!mount.lastMaterializedBranchId)
|
|
596
|
+
return null;
|
|
597
|
+
const current = store.currentBranch();
|
|
598
|
+
if (mount.lastMaterializedBranchId === current.id)
|
|
599
|
+
return null;
|
|
600
|
+
if (this.isLinearContinuation(store, mount.lastMaterializedBranchId, mount.lastMaterializedSeq)) {
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
const pinned = store.listBranches().find((b) => b.id === mount.lastMaterializedBranchId);
|
|
604
|
+
const pinnedName = pinned ? `"${pinned.name}"` : `id ${mount.lastMaterializedBranchId} (branch no longer exists)`;
|
|
605
|
+
return (`current branch "${current.name}" has diverged from the branch last materialized to disk ` +
|
|
606
|
+
`(${pinnedName} at seq ${mount.lastMaterializedSeq}) — disk may hold state the current branch ` +
|
|
607
|
+
`never had. Pass force: true to overwrite disk from the current branch if it is canonical.`);
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Re-pin mounts whose last-materialized branch is a proven ancestor of the
|
|
611
|
+
* current branch. Runs after persisted state is applied on restart, so an
|
|
612
|
+
* out-of-band branch switch (offline repair/treatment branches) heals at
|
|
613
|
+
* boot instead of wedging materialize until manual surgery.
|
|
614
|
+
*/
|
|
615
|
+
healBranchPins() {
|
|
616
|
+
const store = this.store;
|
|
617
|
+
if (!store)
|
|
618
|
+
return;
|
|
619
|
+
const current = store.currentBranch();
|
|
620
|
+
for (const mount of this.mounts.values()) {
|
|
621
|
+
if (mount.lastMaterializedBranchId &&
|
|
622
|
+
mount.lastMaterializedBranchId !== current.id &&
|
|
623
|
+
this.isLinearContinuation(store, mount.lastMaterializedBranchId, mount.lastMaterializedSeq)) {
|
|
624
|
+
console.warn(`[workspace] Mount "${mount.config.name}": branch pin ${mount.lastMaterializedBranchId} → ` +
|
|
625
|
+
`${current.id} ("${current.name}") — current branch linearly continues the last ` +
|
|
626
|
+
`materialized branch (out-of-band switch, e.g. repair); disk state is preserved history.`);
|
|
627
|
+
mount.lastMaterializedBranchId = current.id;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
519
630
|
}
|
|
520
631
|
/**
|
|
521
632
|
* Start chokidar watchers and emit workspace:mounted events.
|
|
@@ -744,6 +855,7 @@ export class WorkspaceModule {
|
|
|
744
855
|
properties: {
|
|
745
856
|
path: { type: 'string', description: 'Specific path to materialize (optional — defaults to all changed)' },
|
|
746
857
|
mount: { type: 'string', description: 'Specific mount (optional)' },
|
|
858
|
+
force: { type: 'boolean', description: 'Materialize even if the current branch has diverged from the branch last written to disk (default false). Only needed for genuine divergence — descendant branches pass automatically.' },
|
|
747
859
|
},
|
|
748
860
|
},
|
|
749
861
|
},
|
|
@@ -858,8 +970,17 @@ export class WorkspaceModule {
|
|
|
858
970
|
/**
|
|
859
971
|
* Resolve a mount-prefixed path (e.g. "tickets/2026-04-22-foo.md") to its
|
|
860
972
|
* absolute filesystem path. Returns null if the mount is unknown or the
|
|
861
|
-
* resolved path escapes the mount root
|
|
862
|
-
*
|
|
973
|
+
* resolved path escapes the mount root.
|
|
974
|
+
*
|
|
975
|
+
* **Lexical containment only.** The guard reasons about `..` segments in the
|
|
976
|
+
* path string; it knows nothing about what is on disk. A symlink inside the
|
|
977
|
+
* mount that targets an outside file passes this check, and a caller that
|
|
978
|
+
* then does `fs.readFile(path)` reads the outside content. Use the returned
|
|
979
|
+
* path for resolution-only purposes (write targets, nonexistent paths,
|
|
980
|
+
* display); for reading file content, use {@link readFileFromDisk}, which
|
|
981
|
+
* enforces the mount's `followSymlinks` policy and canonical containment.
|
|
982
|
+
*
|
|
983
|
+
* @deprecated for direct filesystem reads — call `readFileFromDisk()`.
|
|
863
984
|
*/
|
|
864
985
|
resolveAbsolutePath(mountPrefixedPath) {
|
|
865
986
|
try {
|
|
@@ -1030,72 +1151,97 @@ export class WorkspaceModule {
|
|
|
1030
1151
|
}
|
|
1031
1152
|
return this.validateImageBytes(blob, mountPrefixedPath, maxSize);
|
|
1032
1153
|
}
|
|
1033
|
-
|
|
1154
|
+
/**
|
|
1155
|
+
* Open a mount-relative file for reading with the mount boundary enforced on
|
|
1156
|
+
* the *filesystem*, not just the path string:
|
|
1157
|
+
*
|
|
1158
|
+
* 1. `lstat` the lexical path — refuse directories; refuse a final-component
|
|
1159
|
+
* symlink when the mount does not `followSymlinks`.
|
|
1160
|
+
* 2. Open with `O_NOFOLLOW` where the platform has it (POSIX) so the refusal
|
|
1161
|
+
* holds against a symlink swapped in between lstat and open; where it
|
|
1162
|
+
* doesn't (Windows), re-`lstat` after opening instead.
|
|
1163
|
+
* 3. `realpath` both the mount root and the opened path and require canonical
|
|
1164
|
+
* containment — this is what catches an *intermediate* symlinked
|
|
1165
|
+
* directory escaping the mount, and a followed symlink whose target lands
|
|
1166
|
+
* outside, on every platform (junctions included).
|
|
1167
|
+
* 4. Confirm the descriptor and the canonical path are the same inode.
|
|
1168
|
+
*
|
|
1169
|
+
* `inspect` runs right after the descriptor is stat'd and before the
|
|
1170
|
+
* containment work, so callers can impose size policy in the same order the
|
|
1171
|
+
* image reader always has. The returned handle is the caller's to close.
|
|
1172
|
+
*/
|
|
1173
|
+
async openContainedFile(mount, relativePath, mountPrefixedPath, inspect) {
|
|
1034
1174
|
const lexicalPath = resolve(mount.config.path, relativePath);
|
|
1035
|
-
const
|
|
1175
|
+
const follow = mount.config.followSymlinks === true;
|
|
1176
|
+
const useNoFollow = !follow && typeof fsConstants.O_NOFOLLOW === 'number';
|
|
1036
1177
|
let fileInfo;
|
|
1037
1178
|
try {
|
|
1038
1179
|
fileInfo = await lstat(lexicalPath);
|
|
1039
1180
|
}
|
|
1040
1181
|
catch (err) {
|
|
1041
|
-
|
|
1042
|
-
|
|
1182
|
+
const code = errnoOf(err);
|
|
1183
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
|
1184
|
+
throw new WorkspaceReadError('not_found', `File not found: ${mountPrefixedPath}`, 'lstat', code);
|
|
1043
1185
|
}
|
|
1044
|
-
throw new
|
|
1186
|
+
throw new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}`, 'lstat', code);
|
|
1045
1187
|
}
|
|
1046
1188
|
if (fileInfo.isDirectory()) {
|
|
1047
|
-
throw new
|
|
1189
|
+
throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'lstat');
|
|
1048
1190
|
}
|
|
1049
|
-
if (fileInfo.isSymbolicLink() && !
|
|
1050
|
-
throw new
|
|
1191
|
+
if (fileInfo.isSymbolicLink() && !follow) {
|
|
1192
|
+
throw new WorkspaceReadError('symlink', `Symlinks are not allowed: ${mountPrefixedPath}`, 'lstat');
|
|
1051
1193
|
}
|
|
1052
1194
|
let realMountRoot;
|
|
1053
1195
|
try {
|
|
1054
1196
|
realMountRoot = await realpath(mount.config.path);
|
|
1055
1197
|
}
|
|
1056
|
-
catch {
|
|
1057
|
-
throw new
|
|
1198
|
+
catch (err) {
|
|
1199
|
+
throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}`, 'realpath_root', errnoOf(err));
|
|
1058
1200
|
}
|
|
1059
|
-
let handle
|
|
1201
|
+
let handle;
|
|
1060
1202
|
try {
|
|
1061
1203
|
handle = await open(lexicalPath, fsConstants.O_RDONLY | (useNoFollow ? fsConstants.O_NOFOLLOW : 0));
|
|
1062
1204
|
}
|
|
1063
1205
|
catch (err) {
|
|
1064
|
-
|
|
1065
|
-
|
|
1206
|
+
const code = errnoOf(err);
|
|
1207
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
|
1208
|
+
throw new WorkspaceReadError('not_found', `File not found: ${mountPrefixedPath}`, 'open', code);
|
|
1066
1209
|
}
|
|
1067
|
-
if (!
|
|
1068
|
-
throw new
|
|
1210
|
+
if (!follow && code === 'ELOOP') {
|
|
1211
|
+
throw new WorkspaceReadError('symlink', `Symlinks are not allowed: ${mountPrefixedPath}`, 'open', code);
|
|
1069
1212
|
}
|
|
1070
|
-
throw new
|
|
1213
|
+
throw new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}`, 'open', code);
|
|
1071
1214
|
}
|
|
1072
1215
|
try {
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1216
|
+
let fileStat;
|
|
1217
|
+
try {
|
|
1218
|
+
fileStat = await handle.stat();
|
|
1076
1219
|
}
|
|
1077
|
-
|
|
1078
|
-
|
|
1220
|
+
catch (err) {
|
|
1221
|
+
const code = errnoOf(err);
|
|
1222
|
+
throw new WorkspaceReadError('not_found', code === 'ENOENT' ? `File not found: ${mountPrefixedPath}` : `Unable to read file: ${mountPrefixedPath}`, 'fstat', code);
|
|
1079
1223
|
}
|
|
1080
|
-
if (fileStat.
|
|
1081
|
-
throw new
|
|
1224
|
+
if (fileStat.isDirectory()) {
|
|
1225
|
+
throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'fstat');
|
|
1082
1226
|
}
|
|
1083
|
-
if (fileStat.
|
|
1084
|
-
throw new
|
|
1227
|
+
if (!fileStat.isFile()) {
|
|
1228
|
+
throw new WorkspaceReadError('not_found', `File not found: ${mountPrefixedPath}`, 'fstat');
|
|
1085
1229
|
}
|
|
1086
|
-
|
|
1230
|
+
inspect?.(fileStat);
|
|
1231
|
+
if (!follow && !useNoFollow) {
|
|
1087
1232
|
let postOpenInfo;
|
|
1088
1233
|
try {
|
|
1089
1234
|
postOpenInfo = await lstat(lexicalPath);
|
|
1090
1235
|
}
|
|
1091
1236
|
catch (err) {
|
|
1092
|
-
|
|
1093
|
-
|
|
1237
|
+
const code = errnoOf(err);
|
|
1238
|
+
if (code === 'ENOENT') {
|
|
1239
|
+
throw new WorkspaceReadError('changed', `File changed during read: ${mountPrefixedPath}`, 'post_lstat', code);
|
|
1094
1240
|
}
|
|
1095
|
-
throw new
|
|
1241
|
+
throw new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}`, 'post_lstat', code);
|
|
1096
1242
|
}
|
|
1097
1243
|
if (postOpenInfo.isSymbolicLink()) {
|
|
1098
|
-
throw new
|
|
1244
|
+
throw new WorkspaceReadError('symlink', `Symlinks are not allowed: ${mountPrefixedPath}`, 'post_lstat');
|
|
1099
1245
|
}
|
|
1100
1246
|
}
|
|
1101
1247
|
let realFilePath;
|
|
@@ -1103,27 +1249,156 @@ export class WorkspaceModule {
|
|
|
1103
1249
|
realFilePath = await realpath(lexicalPath);
|
|
1104
1250
|
}
|
|
1105
1251
|
catch (err) {
|
|
1106
|
-
|
|
1107
|
-
|
|
1252
|
+
const code = errnoOf(err);
|
|
1253
|
+
if (code === 'ENOENT') {
|
|
1254
|
+
throw new WorkspaceReadError('changed', `File changed during read: ${mountPrefixedPath}`, 'realpath', code);
|
|
1108
1255
|
}
|
|
1109
|
-
throw new
|
|
1256
|
+
throw new WorkspaceReadError('not_found', `Unable to resolve file: ${mountPrefixedPath}`, 'realpath', code);
|
|
1110
1257
|
}
|
|
1111
1258
|
if (!isContainedPath(realMountRoot, realFilePath)) {
|
|
1112
|
-
throw new
|
|
1259
|
+
throw new WorkspaceReadError('escape', `Symlink escape detected: ${mountPrefixedPath}`, 'realpath');
|
|
1113
1260
|
}
|
|
1114
1261
|
let pathStat;
|
|
1115
1262
|
try {
|
|
1116
1263
|
pathStat = await stat(realFilePath);
|
|
1117
1264
|
}
|
|
1118
1265
|
catch (err) {
|
|
1119
|
-
|
|
1120
|
-
|
|
1266
|
+
const code = errnoOf(err);
|
|
1267
|
+
if (code === 'ENOENT') {
|
|
1268
|
+
throw new WorkspaceReadError('changed', `File changed during read: ${mountPrefixedPath}`, 'stat', code);
|
|
1121
1269
|
}
|
|
1122
|
-
throw new
|
|
1270
|
+
throw new WorkspaceReadError('not_found', `Unable to stat file: ${mountPrefixedPath}`, 'stat', code);
|
|
1123
1271
|
}
|
|
1124
1272
|
if (pathStat.dev !== fileStat.dev || pathStat.ino !== fileStat.ino) {
|
|
1125
|
-
throw new
|
|
1273
|
+
throw new WorkspaceReadError('changed', `File changed during read: ${mountPrefixedPath}`, 'stat');
|
|
1126
1274
|
}
|
|
1275
|
+
return { handle, fileStat, realFilePath };
|
|
1276
|
+
}
|
|
1277
|
+
catch (err) {
|
|
1278
|
+
await handle.close();
|
|
1279
|
+
throw err;
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
/**
|
|
1283
|
+
* Read a workspace file from disk with the mount boundary enforced on the
|
|
1284
|
+
* filesystem (see {@link openContainedFile}). Public API for peer modules
|
|
1285
|
+
* that read mounted files outside the Chronicle tree — the safe replacement
|
|
1286
|
+
* for `resolveAbsolutePath()` + `fs.readFile()`, whose lexical-only guard let
|
|
1287
|
+
* an in-mount symlink smuggle outside content into context (agent-framework
|
|
1288
|
+
* #129, found via connectome-host #101).
|
|
1289
|
+
*
|
|
1290
|
+
* - Honors the mount's `followSymlinks` policy (default: refuse).
|
|
1291
|
+
* - With symlinks allowed, the canonical target must stay beneath the
|
|
1292
|
+
* canonical mount root; a sibling-prefix root (`/mount-other`) does not count.
|
|
1293
|
+
* - `maxBytes` performs a bounded prefix read: at most `maxBytes` bytes are
|
|
1294
|
+
* ever loaded, and `truncated` reports that the file was larger.
|
|
1295
|
+
*
|
|
1296
|
+
* Throws {@link WorkspaceReadError} with a `code` distinguishing unknown
|
|
1297
|
+
* mount, lexical traversal, unavailable mount, missing file, directory,
|
|
1298
|
+
* policy-denied symlink, outside-mount target, and file-changed-during-read.
|
|
1299
|
+
*/
|
|
1300
|
+
async readFileFromDisk(mountPrefixedPath, options = {}) {
|
|
1301
|
+
if (options.maxBytes !== undefined && !(Number.isInteger(options.maxBytes) && options.maxBytes >= 0)) {
|
|
1302
|
+
throw new RangeError('readFileFromDisk: maxBytes must be a non-negative integer');
|
|
1303
|
+
}
|
|
1304
|
+
let mount;
|
|
1305
|
+
let relativePath;
|
|
1306
|
+
try {
|
|
1307
|
+
({ mount, relativePath } = this.parsePath(mountPrefixedPath));
|
|
1308
|
+
}
|
|
1309
|
+
catch (err) {
|
|
1310
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1311
|
+
const code = message.startsWith('Unknown mount') ? 'unknown_mount' : 'traversal';
|
|
1312
|
+
throw new WorkspaceReadError(code, message, 'parse');
|
|
1313
|
+
}
|
|
1314
|
+
if (!relativePath) {
|
|
1315
|
+
throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'parse');
|
|
1316
|
+
}
|
|
1317
|
+
const { handle, fileStat, realFilePath } = await this.openContainedFile(mount, relativePath, mountPrefixedPath);
|
|
1318
|
+
try {
|
|
1319
|
+
const size = fileStat.size;
|
|
1320
|
+
const cap = options.maxBytes;
|
|
1321
|
+
let bytes;
|
|
1322
|
+
let truncated = false;
|
|
1323
|
+
if (cap !== undefined && size > cap) {
|
|
1324
|
+
truncated = true;
|
|
1325
|
+
bytes = Buffer.alloc(cap);
|
|
1326
|
+
let offset = 0;
|
|
1327
|
+
while (offset < cap) {
|
|
1328
|
+
const { bytesRead } = await handle.read(bytes, offset, cap - offset, offset);
|
|
1329
|
+
if (bytesRead === 0)
|
|
1330
|
+
break;
|
|
1331
|
+
offset += bytesRead;
|
|
1332
|
+
}
|
|
1333
|
+
if (offset < cap)
|
|
1334
|
+
bytes = bytes.subarray(0, offset);
|
|
1335
|
+
}
|
|
1336
|
+
else {
|
|
1337
|
+
bytes = await handle.readFile();
|
|
1338
|
+
}
|
|
1339
|
+
return { bytes, size, truncated, mtimeMs: fileStat.mtimeMs, realPath: realFilePath, mount: mount.config.name };
|
|
1340
|
+
}
|
|
1341
|
+
catch (err) {
|
|
1342
|
+
if (err instanceof WorkspaceReadError)
|
|
1343
|
+
throw err;
|
|
1344
|
+
throw new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}`, 'read', errnoOf(err));
|
|
1345
|
+
}
|
|
1346
|
+
finally {
|
|
1347
|
+
await handle.close();
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
/** Map a core read refusal onto the image reader's historical error vocabulary and wording. */
|
|
1351
|
+
imageErrorFromRead(err, mount, mountPrefixedPath) {
|
|
1352
|
+
const p = mountPrefixedPath;
|
|
1353
|
+
switch (err.code) {
|
|
1354
|
+
case 'mount_unavailable':
|
|
1355
|
+
return new WorkspaceImageReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}`);
|
|
1356
|
+
case 'directory':
|
|
1357
|
+
return new WorkspaceImageReadError('directory', `Path is a directory: ${p}`);
|
|
1358
|
+
case 'symlink':
|
|
1359
|
+
return new WorkspaceImageReadError('symlink', `Symlinks are not allowed: ${p}`);
|
|
1360
|
+
case 'escape':
|
|
1361
|
+
return new WorkspaceImageReadError('escape', `Symlink escape detected: ${p}`);
|
|
1362
|
+
case 'changed':
|
|
1363
|
+
return new WorkspaceImageReadError('changed', `Image file changed during read: ${p}`);
|
|
1364
|
+
case 'not_found':
|
|
1365
|
+
default:
|
|
1366
|
+
if (err.message.startsWith('File not found')) {
|
|
1367
|
+
return new WorkspaceImageReadError('not_found', `File not found: ${p}`);
|
|
1368
|
+
}
|
|
1369
|
+
if (err.stage === 'realpath') {
|
|
1370
|
+
return new WorkspaceImageReadError('not_found', `Unable to resolve image file: ${p}`);
|
|
1371
|
+
}
|
|
1372
|
+
if (err.stage === 'stat') {
|
|
1373
|
+
return new WorkspaceImageReadError('not_found', `Unable to stat image file: ${p}`);
|
|
1374
|
+
}
|
|
1375
|
+
return new WorkspaceImageReadError('not_found', `Unable to read image file: ${p}`);
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
async readImageFromFilesystem(mount, relativePath, mountPrefixedPath, maxSize) {
|
|
1379
|
+
let opened;
|
|
1380
|
+
try {
|
|
1381
|
+
opened = await this.openContainedFile(mount, relativePath, mountPrefixedPath, (fileStat) => {
|
|
1382
|
+
if (fileStat.size === 0) {
|
|
1383
|
+
throw new WorkspaceImageReadError('empty', `Image file is empty: ${mountPrefixedPath}`);
|
|
1384
|
+
}
|
|
1385
|
+
if (fileStat.size > maxSize) {
|
|
1386
|
+
throw new WorkspaceImageReadError('too_large', `Image file exceeds max size (${maxSize} bytes): ${mountPrefixedPath}`);
|
|
1387
|
+
}
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
catch (err) {
|
|
1391
|
+
if (err instanceof WorkspaceImageReadError)
|
|
1392
|
+
throw err;
|
|
1393
|
+
if (err instanceof WorkspaceReadError)
|
|
1394
|
+
throw this.imageErrorFromRead(err, mount, mountPrefixedPath);
|
|
1395
|
+
if (isErrnoCode(err, 'ENOENT')) {
|
|
1396
|
+
throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
|
|
1397
|
+
}
|
|
1398
|
+
throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
|
|
1399
|
+
}
|
|
1400
|
+
const { handle } = opened;
|
|
1401
|
+
try {
|
|
1127
1402
|
const bytes = await handle.readFile();
|
|
1128
1403
|
return this.validateImageBytes(bytes, mountPrefixedPath, maxSize);
|
|
1129
1404
|
}
|
|
@@ -1136,7 +1411,7 @@ export class WorkspaceModule {
|
|
|
1136
1411
|
throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
|
|
1137
1412
|
}
|
|
1138
1413
|
finally {
|
|
1139
|
-
await handle
|
|
1414
|
+
await handle.close();
|
|
1140
1415
|
}
|
|
1141
1416
|
}
|
|
1142
1417
|
/**
|
|
@@ -1641,26 +1916,13 @@ export class WorkspaceModule {
|
|
|
1641
1916
|
initialSyncDone: mount.initialSyncDone,
|
|
1642
1917
|
currentBranch: currentBranch.name,
|
|
1643
1918
|
lastMaterializedBranch: mount.lastMaterializedBranchId,
|
|
1644
|
-
canMaterialize:
|
|
1919
|
+
canMaterialize: this.mountMaterializeBlockReason(store, mount) === null,
|
|
1645
1920
|
};
|
|
1646
1921
|
}
|
|
1647
1922
|
return { success: true, data: status };
|
|
1648
1923
|
}
|
|
1649
1924
|
async handleMaterialize(input) {
|
|
1650
1925
|
const store = this.getStore();
|
|
1651
|
-
// Guard: only materialize on the active branch
|
|
1652
|
-
if (this.config.materializeOnlyActiveBranch !== false) {
|
|
1653
|
-
const currentBranch = store.currentBranch();
|
|
1654
|
-
for (const mount of this.mounts.values()) {
|
|
1655
|
-
if (mount.lastMaterializedBranchId && mount.lastMaterializedBranchId !== currentBranch.id) {
|
|
1656
|
-
return {
|
|
1657
|
-
success: false,
|
|
1658
|
-
error: `Cannot materialize: current branch "${currentBranch.name}" differs from last materialized branch. Switch back or set materializeOnlyActiveBranch: false.`,
|
|
1659
|
-
isError: true,
|
|
1660
|
-
};
|
|
1661
|
-
}
|
|
1662
|
-
}
|
|
1663
|
-
}
|
|
1664
1926
|
const allWritten = [];
|
|
1665
1927
|
let mountsToMaterialize;
|
|
1666
1928
|
if (input.mount) {
|
|
@@ -1675,6 +1937,35 @@ export class WorkspaceModule {
|
|
|
1675
1937
|
.filter(([, m]) => m.config.mode === 'read-write')
|
|
1676
1938
|
.map(([name, mount]) => ({ name, mount }));
|
|
1677
1939
|
}
|
|
1940
|
+
// Branch guard, scoped to the mounts actually being materialized: a
|
|
1941
|
+
// linear continuation (current branch descends from the pinned branch at
|
|
1942
|
+
// or after the pinned seq) passes; genuine divergence refuses unless
|
|
1943
|
+
// force. One mount's stale pin must never block another mount.
|
|
1944
|
+
const blocked = [];
|
|
1945
|
+
for (const { name, mount } of mountsToMaterialize) {
|
|
1946
|
+
const reason = this.mountMaterializeBlockReason(store, mount);
|
|
1947
|
+
if (!reason)
|
|
1948
|
+
continue;
|
|
1949
|
+
if (input.force) {
|
|
1950
|
+
// Disk reflects another line of history, so an incremental diff from
|
|
1951
|
+
// the pinned seq is meaningless — reset tracking and re-materialize
|
|
1952
|
+
// the full tree, exactly like materializeMount() after a deliberate
|
|
1953
|
+
// branch switch.
|
|
1954
|
+
mount.lastMaterializedSeq = 0;
|
|
1955
|
+
mount.lastMaterializedBranchId = null;
|
|
1956
|
+
}
|
|
1957
|
+
else {
|
|
1958
|
+
blocked.push({ mount: name, reason });
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
mountsToMaterialize = mountsToMaterialize.filter(({ name }) => !blocked.some((b) => b.mount === name));
|
|
1962
|
+
if (mountsToMaterialize.length === 0 && blocked.length > 0) {
|
|
1963
|
+
return {
|
|
1964
|
+
success: false,
|
|
1965
|
+
error: `Cannot materialize: ${blocked.map((b) => `[${b.mount}] ${b.reason}`).join('; ')}`,
|
|
1966
|
+
isError: true,
|
|
1967
|
+
};
|
|
1968
|
+
}
|
|
1678
1969
|
for (const { name, mount } of mountsToMaterialize) {
|
|
1679
1970
|
let paths;
|
|
1680
1971
|
if (input.path) {
|
|
@@ -1688,8 +1979,12 @@ export class WorkspaceModule {
|
|
|
1688
1979
|
watcher?.suppress(p);
|
|
1689
1980
|
allWritten.push({ mount: name, path: p });
|
|
1690
1981
|
}
|
|
1691
|
-
// Track which branch we materialized on
|
|
1692
|
-
|
|
1982
|
+
// Track which branch we materialized on. Re-pin on a clean empty
|
|
1983
|
+
// materialize too (previously-pinned mount, nothing pending): disk
|
|
1984
|
+
// already reflects the current branch's tree, and leaving the old pin
|
|
1985
|
+
// would keep force required forever after a cross-branch materialize
|
|
1986
|
+
// that happened to write nothing.
|
|
1987
|
+
if (written.length > 0 || mount.lastMaterializedBranchId !== null) {
|
|
1693
1988
|
mount.lastMaterializedBranchId = store.currentBranch().id;
|
|
1694
1989
|
}
|
|
1695
1990
|
}
|
|
@@ -1698,6 +1993,7 @@ export class WorkspaceModule {
|
|
|
1698
1993
|
data: {
|
|
1699
1994
|
materialized: allWritten,
|
|
1700
1995
|
count: allWritten.length,
|
|
1996
|
+
...(blocked.length > 0 ? { skipped: blocked } : {}),
|
|
1701
1997
|
},
|
|
1702
1998
|
};
|
|
1703
1999
|
}
|