@gmickel/gno 2.7.0 → 2.8.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/README.md +26 -30
- package/assets/skill/SKILL.md +8 -1
- package/assets/skill/cli-reference.md +8 -1
- package/assets/skill/examples.md +2 -1
- package/assets/skill/mcp-reference.md +3 -1
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v2.7.0.zip → gno-browser-clipper-v2.8.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.8.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +2 -1
- package/spec/cli.md +48 -6
- package/spec/db/schema.sql +0 -1
- package/spec/mcp.md +18 -3
- package/spec/output-schemas/audit-report.schema.json +18 -4
- package/spec/output-schemas/backlinks.schema.json +4 -0
- package/spec/output-schemas/collection-list.schema.json +15 -2
- package/spec/output-schemas/graph.schema.json +2 -0
- package/spec/output-schemas/links-list.schema.json +4 -0
- package/spec/output-schemas/status.schema.json +27 -16
- package/src/cli/commands/ask.ts +31 -12
- package/src/cli/commands/audit.ts +23 -4
- package/src/cli/commands/collection/list.ts +39 -5
- package/src/cli/commands/embed.ts +3 -3
- package/src/cli/commands/links.ts +34 -131
- package/src/cli/commands/ls.ts +6 -1
- package/src/cli/commands/shared.ts +7 -0
- package/src/cli/commands/status.ts +5 -0
- package/src/cli/program.ts +12 -2
- package/src/config/loader.ts +43 -0
- package/src/config/types.ts +8 -0
- package/src/core/audit-contract.ts +16 -4
- package/src/core/audit-freshness.ts +11 -1
- package/src/core/audit-links.ts +145 -25
- package/src/core/audit-provenance.ts +11 -4
- package/src/core/audit-workspace.ts +19 -4
- package/src/core/audit.ts +67 -15
- package/src/core/context-compiler.ts +3 -0
- package/src/core/context-evidence.ts +11 -0
- package/src/core/graph-edge-confidence.ts +23 -1
- package/src/core/host-paths.ts +24 -5
- package/src/core/knowledge-impact.ts +28 -0
- package/src/core/link-workspace.ts +324 -0
- package/src/core/request-receipts.ts +63 -9
- package/src/core/retrieval-replay-candidate.ts +6 -0
- package/src/core/retrieval-trace-request.ts +3 -0
- package/src/core/windows-private-path.ts +136 -1
- package/src/embed/backlog.ts +22 -10
- package/src/embed/variant-backlog.ts +34 -9
- package/src/index.ts +14 -1
- package/src/ingestion/graph-reconciliation.ts +77 -15
- package/src/ingestion/source-availability/darwin-path.ts +9 -3
- package/src/ingestion/sync.ts +22 -1
- package/src/ingestion/types.ts +14 -0
- package/src/llm/inference-scope.ts +19 -0
- package/src/mcp/http-egress.ts +42 -3
- package/src/mcp/tools/audit.ts +11 -2
- package/src/mcp/tools/changes.ts +1 -0
- package/src/mcp/tools/links.ts +3 -0
- package/src/mcp/tools/sessions.ts +33 -4
- package/src/mcp/tools/status.ts +22 -6
- package/src/pipeline/expansion.ts +19 -31
- package/src/pipeline/graph-retrieval.ts +22 -2
- package/src/pipeline/hybrid.ts +1 -1
- package/src/pipeline/types.ts +6 -3
- package/src/serve/embed-scheduler.ts +2 -2
- package/src/serve/findings-pass.ts +1 -1
- package/src/serve/host-path-redaction.ts +51 -14
- package/src/serve/public/components/BootstrapStatus.tsx +5 -3
- package/src/serve/public/components/CaptureModal.tsx +1 -1
- package/src/serve/public/components/CollectionModelDialog.tsx +16 -13
- package/src/serve/public/components/CollectionsEmptyState.tsx +5 -3
- package/src/serve/public/components/FirstRunWizard.tsx +4 -2
- package/src/serve/public/components/sessions/SourcesPanel.tsx +77 -60
- package/src/serve/public/pages/Collections.tsx +16 -13
- package/src/serve/public/pages/Connectors.tsx +7 -4
- package/src/serve/public/pages/Dashboard.tsx +8 -6
- package/src/serve/public/pages/GraphView.tsx +2 -0
- package/src/serve/resident-runtime.ts +7 -0
- package/src/serve/routes/changes.ts +6 -1
- package/src/serve/routes/links.ts +13 -0
- package/src/serve/routes/sessions.ts +81 -37
- package/src/serve/server.ts +5 -3
- package/src/serve/status-model.ts +10 -6
- package/src/serve/status.ts +2 -7
- package/src/sessions/config-refresh.ts +111 -0
- package/src/store/migrations/033-drop-documents-active-index.ts +30 -0
- package/src/store/migrations/034-collection-link-workspace.ts +47 -0
- package/src/store/migrations/index.ts +4 -0
- package/src/store/sqlite/adapter.ts +392 -233
- package/src/store/sqlite/change-journal-store.ts +1 -1
- package/src/store/sqlite/eligibility.ts +8 -2
- package/src/store/sqlite/graph-link-resolver.ts +252 -5
- package/src/store/sqlite/graph-neighbors.ts +147 -40
- package/src/store/sqlite/graph-reference-state.ts +13 -2
- package/src/store/sqlite/legacy-vector-ownership.ts +2 -1
- package/src/store/sqlite/workspace-link-resolver.ts +654 -0
- package/src/store/types.ts +49 -3
- package/src/store/vector/stats.ts +1 -1
- package/browser-extension/artifacts/gno-browser-clipper-v2.7.0.zip.sha256 +0 -1
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
import { planContextEvidence } from "./context-compiler";
|
|
41
41
|
import { projectContextEvidenceMetadata } from "./context-evidence-metadata";
|
|
42
42
|
import { createEgressLineage, resolveEgressLineage } from "./egress-provenance";
|
|
43
|
+
import { linkResolutionFingerprintInput } from "./link-workspace";
|
|
43
44
|
import { projectRecordEvidenceMetadata } from "./record-metadata";
|
|
44
45
|
import {
|
|
45
46
|
extractInclusiveLines,
|
|
@@ -205,6 +206,13 @@ const canonicalIndexSnapshot = (
|
|
|
205
206
|
),
|
|
206
207
|
});
|
|
207
208
|
|
|
209
|
+
const linkResolutionEntry = (
|
|
210
|
+
rows: Parameters<typeof linkResolutionFingerprintInput>[0]
|
|
211
|
+
): { linkResolution?: ReturnType<typeof linkResolutionFingerprintInput> } => {
|
|
212
|
+
const linkResolution = linkResolutionFingerprintInput(rows);
|
|
213
|
+
return linkResolution ? { linkResolution } : {};
|
|
214
|
+
};
|
|
215
|
+
|
|
208
216
|
/** Capture one strict, content-free index/context snapshot before or after work. */
|
|
209
217
|
export const captureContextEvidenceSnapshot = async (
|
|
210
218
|
store: ContextEvidenceStore,
|
|
@@ -284,6 +292,9 @@ export const captureContextEvidenceSnapshot = async (
|
|
|
284
292
|
indexFingerprint: hashJson({
|
|
285
293
|
snapshots: indexSnapshots,
|
|
286
294
|
egressLineage,
|
|
295
|
+
// Link workspace membership decides graph neighbours; a membership or
|
|
296
|
+
// resolver change is index drift. Absent without any workspace.
|
|
297
|
+
...linkResolutionEntry(collectionRows),
|
|
287
298
|
}),
|
|
288
299
|
};
|
|
289
300
|
};
|
|
@@ -17,8 +17,30 @@ export const GRAPH_EDGE_CONFIDENCE_RANK: Record<GraphEdgeConfidence, number> = {
|
|
|
17
17
|
export function classifyResolvedGraphEdge(
|
|
18
18
|
linkType: "wiki" | "markdown",
|
|
19
19
|
matchRank: number | null,
|
|
20
|
-
matchCount: number | null
|
|
20
|
+
matchCount: number | null,
|
|
21
|
+
/** Workspace resolution reason; semantic, independent of legacy ranks. */
|
|
22
|
+
reason?: string
|
|
21
23
|
): { confidence: GraphEdgeConfidence; audit: GraphEdgeAudit } {
|
|
24
|
+
if (linkType === "wiki" && reason !== undefined && reason !== "title") {
|
|
25
|
+
switch (reason) {
|
|
26
|
+
case "workspace-path":
|
|
27
|
+
case "collection-path":
|
|
28
|
+
return {
|
|
29
|
+
confidence: "explicit",
|
|
30
|
+
audit: { resolution: "exact-path", matchCount: 1 },
|
|
31
|
+
};
|
|
32
|
+
case "exact-name":
|
|
33
|
+
return {
|
|
34
|
+
confidence: "explicit",
|
|
35
|
+
audit: { resolution: "exact-name", matchCount: 1 },
|
|
36
|
+
};
|
|
37
|
+
default:
|
|
38
|
+
return {
|
|
39
|
+
confidence: "inferred",
|
|
40
|
+
audit: { resolution: "tie-break", matchCount: 1 },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
22
44
|
if (linkType === "markdown") {
|
|
23
45
|
return {
|
|
24
46
|
confidence: "explicit",
|
package/src/core/host-paths.ts
CHANGED
|
@@ -6,26 +6,45 @@
|
|
|
6
6
|
* peek receipts). A remote caller identifies documents by `uri` and
|
|
7
7
|
* collection-relative `relPath` instead, so every `absPath` key is removed
|
|
8
8
|
* before a payload leaves a remote-reachable surface.
|
|
9
|
+
*
|
|
10
|
+
* Status, collection, and connector payloads also name owner configuration
|
|
11
|
+
* locations: `configPath`, `dbPath`, and `path` (collection roots, suggested
|
|
12
|
+
* folders, model cache and model files, connector targets). A remote caller
|
|
13
|
+
* identifies a collection by `name`, so those keys are removed from those
|
|
14
|
+
* payloads too.
|
|
9
15
|
*/
|
|
10
16
|
|
|
11
|
-
|
|
17
|
+
/** Document host path key of result payloads. */
|
|
18
|
+
export const HOST_PATH_FIELDS: ReadonlySet<string> = new Set(["absPath"]);
|
|
19
|
+
|
|
20
|
+
/** Owner configuration path keys of status, collection, and connector payloads. */
|
|
21
|
+
export const OWNER_CONFIG_PATH_FIELDS: ReadonlySet<string> = new Set([
|
|
22
|
+
"configPath",
|
|
23
|
+
"dbPath",
|
|
24
|
+
"path",
|
|
25
|
+
"workspaceRoot",
|
|
26
|
+
]);
|
|
12
27
|
|
|
13
28
|
const isPlainObject = (value: object): boolean => {
|
|
14
29
|
const proto = Object.getPrototypeOf(value) as object | null;
|
|
15
30
|
return proto === Object.prototype || proto === null;
|
|
16
31
|
};
|
|
17
32
|
|
|
18
|
-
/** Deep copy of a JSON-shaped value with every `
|
|
19
|
-
export function
|
|
33
|
+
/** Deep copy of a JSON-shaped value with every key in `fields` removed. */
|
|
34
|
+
export function withoutFields<T>(value: T, fields: ReadonlySet<string>): T {
|
|
20
35
|
if (Array.isArray(value)) {
|
|
21
|
-
return value.map((item: unknown) =>
|
|
36
|
+
return value.map((item: unknown) => withoutFields(item, fields)) as T;
|
|
22
37
|
}
|
|
23
38
|
if (value === null || typeof value !== "object" || !isPlainObject(value)) {
|
|
24
39
|
return value;
|
|
25
40
|
}
|
|
26
41
|
const copy: Record<string, unknown> = {};
|
|
27
42
|
for (const [key, entry] of Object.entries(value)) {
|
|
28
|
-
if (key
|
|
43
|
+
if (!fields.has(key)) copy[key] = withoutFields(entry, fields);
|
|
29
44
|
}
|
|
30
45
|
return copy as T;
|
|
31
46
|
}
|
|
47
|
+
|
|
48
|
+
/** Deep copy of a JSON-shaped value with every `absPath` key removed. */
|
|
49
|
+
export const withoutHostPaths = <T>(value: T): T =>
|
|
50
|
+
withoutFields(value, HOST_PATH_FIELDS);
|
|
@@ -41,6 +41,12 @@ export interface KnowledgeImpactResult {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
export interface KnowledgeImpactInput {
|
|
44
|
+
/**
|
|
45
|
+
* Collection scope: only documents in these collections are traversed or
|
|
46
|
+
* returned, and a document outside them is never used as a bridge.
|
|
47
|
+
* Omitted or empty means every indexed collection.
|
|
48
|
+
*/
|
|
49
|
+
collections?: string[];
|
|
44
50
|
maxDepth?: number;
|
|
45
51
|
maxNodes?: number;
|
|
46
52
|
maxEdges?: number;
|
|
@@ -118,8 +124,30 @@ export async function analyzeKnowledgeImpact(
|
|
|
118
124
|
isValidation: true,
|
|
119
125
|
};
|
|
120
126
|
}
|
|
127
|
+
const scope = [...new Set(input.collections ?? [])].sort();
|
|
128
|
+
if (scope.length > 0) {
|
|
129
|
+
const known = await store.getCollections();
|
|
130
|
+
if (!known.ok) return { success: false, error: known.error.message };
|
|
131
|
+
const names = new Set(known.value.map((row) => row.name));
|
|
132
|
+
const missing = scope.find((name) => !names.has(name));
|
|
133
|
+
if (missing) {
|
|
134
|
+
return {
|
|
135
|
+
success: false,
|
|
136
|
+
error: `Collection not found: ${missing}`,
|
|
137
|
+
isValidation: true,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (!scope.includes(resolved.doc.collection)) {
|
|
141
|
+
return {
|
|
142
|
+
success: false,
|
|
143
|
+
error: `Document is outside the requested collections: ${ref}`,
|
|
144
|
+
isValidation: true,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
121
148
|
const traversal = await store.queryGraphTraversal(resolved.doc.id, {
|
|
122
149
|
direction: "in",
|
|
150
|
+
...(scope.length > 0 ? { collections: scope } : {}),
|
|
123
151
|
maxDepth: values.maxDepth,
|
|
124
152
|
maxNodes: values.maxNodes,
|
|
125
153
|
frontierLimit: values.frontierLimit,
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link workspaces: collections whose roots share one workspace root resolve
|
|
3
|
+
* plain wiki links across each other (Obsidian vault semantics).
|
|
4
|
+
*
|
|
5
|
+
* Membership is derived from the filesystem, never stored in content: the
|
|
6
|
+
* nearest ancestor-or-self directory containing `.obsidian/`, or an explicit
|
|
7
|
+
* per-collection `workspaceRoot` (absolute path, or `false` to opt out).
|
|
8
|
+
* Ownership is finally decided per document: a nested vault (`.obsidian/`
|
|
9
|
+
* below the collection root) forms its own workspace.
|
|
10
|
+
*
|
|
11
|
+
* @module src/core/link-workspace
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// node:fs has no Bun equivalent for synchronous realpath/stat of directories.
|
|
15
|
+
import { realpathSync, statSync } from "node:fs";
|
|
16
|
+
// node:path provides platform-correct path algebra; Bun has no path utilities.
|
|
17
|
+
import { dirname, isAbsolute, relative, sep } from "node:path";
|
|
18
|
+
|
|
19
|
+
/** Bump when resolution semantics change; enters projection fingerprints. */
|
|
20
|
+
export const LINK_RESOLVER_VERSION = 2;
|
|
21
|
+
|
|
22
|
+
export const WORKSPACE_MARKER_DIR = ".obsidian";
|
|
23
|
+
|
|
24
|
+
export type LinkWorkspaceSource =
|
|
25
|
+
/** No workspace root: collection-scoped resolution (today's behaviour). */
|
|
26
|
+
| "none"
|
|
27
|
+
/** Nearest `.obsidian/` ancestor-or-self of the collection root. */
|
|
28
|
+
| "detected"
|
|
29
|
+
/** Explicit absolute `workspaceRoot`. */
|
|
30
|
+
| "configured"
|
|
31
|
+
/** Explicit `workspaceRoot: false` opt-out. */
|
|
32
|
+
| "disabled"
|
|
33
|
+
/** Root or ancestors could not be inspected; fails closed to collection scope. */
|
|
34
|
+
| "unavailable";
|
|
35
|
+
|
|
36
|
+
export interface CollectionWorkspaceInfo {
|
|
37
|
+
/** Canonical (real) collection root, or null when it cannot be resolved. */
|
|
38
|
+
realPath: string | null;
|
|
39
|
+
/** Canonical workspace root, or null for collection-scoped resolution. */
|
|
40
|
+
root: string | null;
|
|
41
|
+
source: LinkWorkspaceSource;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Stored membership of one collection, as read back from the index. */
|
|
45
|
+
export interface CollectionWorkspaceMembership extends CollectionWorkspaceInfo {
|
|
46
|
+
collection: string;
|
|
47
|
+
/** Collection-relative POSIX prefixes that hold their own `.obsidian/`. */
|
|
48
|
+
nested: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type DirProbe = "present" | "absent" | "error";
|
|
52
|
+
|
|
53
|
+
const probeMarker = (directory: string): DirProbe => {
|
|
54
|
+
try {
|
|
55
|
+
return statSync(`${directory}${sep}${WORKSPACE_MARKER_DIR}`).isDirectory()
|
|
56
|
+
? "present"
|
|
57
|
+
: "absent";
|
|
58
|
+
} catch (cause) {
|
|
59
|
+
const code = (cause as NodeJS.ErrnoException | undefined)?.code;
|
|
60
|
+
return code === "ENOENT" || code === "ENOTDIR" ? "absent" : "error";
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const canonical = (path: string): string | null => {
|
|
65
|
+
try {
|
|
66
|
+
return realpathSync(path).normalize("NFC");
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Join a POSIX relative path onto a native absolute root. */
|
|
73
|
+
const nativeJoin = (root: string, posixPath: string): string =>
|
|
74
|
+
`${root}${sep}${posixPath.split("/").join(sep)}`;
|
|
75
|
+
|
|
76
|
+
/** True when `child` equals `parent` or sits inside it (component-wise). */
|
|
77
|
+
export const pathContains = (parent: string, child: string): boolean => {
|
|
78
|
+
const rel = relative(parent, child);
|
|
79
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** POSIX path of `child` relative to `parent` ('' when equal). */
|
|
83
|
+
export const posixRelative = (parent: string, child: string): string =>
|
|
84
|
+
relative(parent, child).split(sep).join("/").normalize("NFC");
|
|
85
|
+
|
|
86
|
+
export type WorkspaceRootSettingError =
|
|
87
|
+
| "not_absolute"
|
|
88
|
+
| "not_found"
|
|
89
|
+
| "not_containing";
|
|
90
|
+
|
|
91
|
+
/** Validate an explicit `workspaceRoot` against the collection root. */
|
|
92
|
+
export const validateWorkspaceRootSetting = (
|
|
93
|
+
collectionPath: string,
|
|
94
|
+
workspaceRoot: string
|
|
95
|
+
): WorkspaceRootSettingError | null => {
|
|
96
|
+
if (!isAbsolute(workspaceRoot)) return "not_absolute";
|
|
97
|
+
const root = canonical(workspaceRoot);
|
|
98
|
+
if (root === null) return "not_found";
|
|
99
|
+
try {
|
|
100
|
+
if (!statSync(root).isDirectory()) return "not_found";
|
|
101
|
+
} catch {
|
|
102
|
+
return "not_found";
|
|
103
|
+
}
|
|
104
|
+
const collectionRoot = canonical(collectionPath) ?? collectionPath;
|
|
105
|
+
return pathContains(root, collectionRoot) ? null : "not_containing";
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export const workspaceRootSettingMessage = (
|
|
109
|
+
collection: string,
|
|
110
|
+
error: WorkspaceRootSettingError
|
|
111
|
+
): string => {
|
|
112
|
+
switch (error) {
|
|
113
|
+
case "not_absolute":
|
|
114
|
+
return `Collection "${collection}": workspaceRoot must be an absolute path`;
|
|
115
|
+
case "not_found":
|
|
116
|
+
return `Collection "${collection}": workspaceRoot does not exist or is not a directory`;
|
|
117
|
+
case "not_containing":
|
|
118
|
+
return `Collection "${collection}": workspaceRoot must contain the collection root`;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Resolve one collection's workspace from its root. Real paths are used
|
|
124
|
+
* throughout; an unreadable root or ancestor never joins a broader workspace.
|
|
125
|
+
*/
|
|
126
|
+
export const detectCollectionWorkspace = (collection: {
|
|
127
|
+
path: string;
|
|
128
|
+
workspaceRoot?: string | false;
|
|
129
|
+
}): CollectionWorkspaceInfo => {
|
|
130
|
+
const realPath = canonical(collection.path);
|
|
131
|
+
if (collection.workspaceRoot === false) {
|
|
132
|
+
return { realPath, root: null, source: "disabled" };
|
|
133
|
+
}
|
|
134
|
+
if (realPath === null) {
|
|
135
|
+
return { realPath: null, root: null, source: "unavailable" };
|
|
136
|
+
}
|
|
137
|
+
if (typeof collection.workspaceRoot === "string") {
|
|
138
|
+
const invalid = validateWorkspaceRootSetting(
|
|
139
|
+
collection.path,
|
|
140
|
+
collection.workspaceRoot
|
|
141
|
+
);
|
|
142
|
+
const root = invalid ? null : canonical(collection.workspaceRoot);
|
|
143
|
+
return root === null
|
|
144
|
+
? { realPath, root: null, source: "unavailable" }
|
|
145
|
+
: { realPath, root, source: "configured" };
|
|
146
|
+
}
|
|
147
|
+
let current = realPath;
|
|
148
|
+
for (;;) {
|
|
149
|
+
const probe = probeMarker(current);
|
|
150
|
+
if (probe === "present") {
|
|
151
|
+
return { realPath, root: current, source: "detected" };
|
|
152
|
+
}
|
|
153
|
+
if (probe === "error") {
|
|
154
|
+
return { realPath, root: null, source: "unavailable" };
|
|
155
|
+
}
|
|
156
|
+
const parent = dirname(current);
|
|
157
|
+
if (parent === current) {
|
|
158
|
+
return { realPath, root: null, source: "none" };
|
|
159
|
+
}
|
|
160
|
+
current = parent;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Find nested vaults inside a collection: every ancestor directory of an
|
|
166
|
+
* indexed document (below the collection root) that holds `.obsidian/`.
|
|
167
|
+
* Bounded by distinct directories, never by link count.
|
|
168
|
+
*/
|
|
169
|
+
export const detectNestedWorkspacePrefixes = (
|
|
170
|
+
realRoot: string,
|
|
171
|
+
relPaths: Iterable<string>
|
|
172
|
+
): string[] => {
|
|
173
|
+
const directories = new Set<string>();
|
|
174
|
+
for (const relPath of relPaths) {
|
|
175
|
+
let directory = relPath.includes("/")
|
|
176
|
+
? relPath.slice(0, relPath.lastIndexOf("/"))
|
|
177
|
+
: "";
|
|
178
|
+
while (directory && !directories.has(directory)) {
|
|
179
|
+
directories.add(directory);
|
|
180
|
+
directory = directory.includes("/")
|
|
181
|
+
? directory.slice(0, directory.lastIndexOf("/"))
|
|
182
|
+
: "";
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const nested: string[] = [];
|
|
186
|
+
for (const directory of directories) {
|
|
187
|
+
if (probeMarker(nativeJoin(realRoot, directory)) === "present") {
|
|
188
|
+
nested.push(directory.normalize("NFC"));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return nested.sort();
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Fingerprint input for effective link resolution: resolver version plus the
|
|
196
|
+
* membership of every collection that belongs to (or contains) a workspace.
|
|
197
|
+
* Undefined when no collection is in a workspace, so collection-scoped
|
|
198
|
+
* indexes keep their existing fingerprints.
|
|
199
|
+
*/
|
|
200
|
+
export const linkResolutionFingerprintInput = (
|
|
201
|
+
rows: ReadonlyArray<{
|
|
202
|
+
name: string;
|
|
203
|
+
realPath?: string | null;
|
|
204
|
+
workspaceRoot?: string | null;
|
|
205
|
+
workspaceSource?: LinkWorkspaceSource;
|
|
206
|
+
workspaceNested?: string[];
|
|
207
|
+
}>
|
|
208
|
+
):
|
|
209
|
+
| {
|
|
210
|
+
version: number;
|
|
211
|
+
workspaces: Array<{
|
|
212
|
+
collection: string;
|
|
213
|
+
realPath: string | null;
|
|
214
|
+
root: string | null;
|
|
215
|
+
source: LinkWorkspaceSource;
|
|
216
|
+
nested: string[];
|
|
217
|
+
}>;
|
|
218
|
+
}
|
|
219
|
+
| undefined => {
|
|
220
|
+
const workspaces = rows
|
|
221
|
+
.filter(
|
|
222
|
+
(row) =>
|
|
223
|
+
(row.workspaceRoot ?? null) !== null ||
|
|
224
|
+
(row.workspaceNested?.length ?? 0) > 0
|
|
225
|
+
)
|
|
226
|
+
.map((row) => ({
|
|
227
|
+
collection: row.name,
|
|
228
|
+
realPath: row.realPath ?? null,
|
|
229
|
+
root: row.workspaceRoot ?? null,
|
|
230
|
+
source: row.workspaceSource ?? "none",
|
|
231
|
+
nested: [...(row.workspaceNested ?? [])].sort(),
|
|
232
|
+
}))
|
|
233
|
+
.sort((left, right) =>
|
|
234
|
+
left.collection < right.collection
|
|
235
|
+
? -1
|
|
236
|
+
: left.collection > right.collection
|
|
237
|
+
? 1
|
|
238
|
+
: 0
|
|
239
|
+
);
|
|
240
|
+
return workspaces.length > 0
|
|
241
|
+
? { version: LINK_RESOLVER_VERSION, workspaces }
|
|
242
|
+
: undefined;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* One-line link workspace description for status output, or null for a
|
|
247
|
+
* collection that is not in a workspace. A redacted root prints no path.
|
|
248
|
+
*/
|
|
249
|
+
export const formatLinkWorkspace = (collection: {
|
|
250
|
+
workspaceRoot?: string | null;
|
|
251
|
+
workspaceSource?: string;
|
|
252
|
+
}): string | null => {
|
|
253
|
+
switch (collection.workspaceSource) {
|
|
254
|
+
case "detected":
|
|
255
|
+
case "configured":
|
|
256
|
+
return collection.workspaceRoot
|
|
257
|
+
? `${collection.workspaceRoot} (${collection.workspaceSource})`
|
|
258
|
+
: collection.workspaceSource;
|
|
259
|
+
case "disabled":
|
|
260
|
+
return "off (links stay inside this collection)";
|
|
261
|
+
case "unavailable":
|
|
262
|
+
return "unavailable (links stay inside this collection)";
|
|
263
|
+
default:
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
/** Workspace identity and workspace-relative path of one document. */
|
|
269
|
+
export interface DocumentWorkspacePlacement {
|
|
270
|
+
/** Canonical workspace root (the workspace identity), or null. */
|
|
271
|
+
key: string | null;
|
|
272
|
+
/** Document path relative to the workspace root (POSIX, NFC). */
|
|
273
|
+
path: string;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Collection root relative to its workspace root, per membership object. */
|
|
277
|
+
const workspacePrefixes = new WeakMap<CollectionWorkspaceMembership, string>();
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Place a document into its workspace: the deepest nested vault containing
|
|
281
|
+
* it, else its collection's workspace. Collection-scoped collections (none,
|
|
282
|
+
* disabled, unavailable) never place documents into a workspace, except that
|
|
283
|
+
* nested vaults still own their documents when the collection root is known
|
|
284
|
+
* and not opted out.
|
|
285
|
+
*/
|
|
286
|
+
export const placeDocument = (
|
|
287
|
+
membership: CollectionWorkspaceMembership | undefined,
|
|
288
|
+
relPath: string
|
|
289
|
+
): DocumentWorkspacePlacement => {
|
|
290
|
+
const normalized = relPath.normalize("NFC");
|
|
291
|
+
if (
|
|
292
|
+
!membership ||
|
|
293
|
+
membership.realPath === null ||
|
|
294
|
+
membership.source === "disabled" ||
|
|
295
|
+
membership.source === "unavailable"
|
|
296
|
+
) {
|
|
297
|
+
return { key: null, path: normalized };
|
|
298
|
+
}
|
|
299
|
+
let nestedPrefix: string | null = null;
|
|
300
|
+
for (const prefix of membership.nested) {
|
|
301
|
+
if (
|
|
302
|
+
normalized.startsWith(`${prefix}/`) &&
|
|
303
|
+
(nestedPrefix === null || prefix.length > nestedPrefix.length)
|
|
304
|
+
) {
|
|
305
|
+
nestedPrefix = prefix;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (nestedPrefix !== null) {
|
|
309
|
+
return {
|
|
310
|
+
key: nativeJoin(membership.realPath, nestedPrefix),
|
|
311
|
+
path: normalized.slice(nestedPrefix.length + 1),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
if (membership.root === null) return { key: null, path: normalized };
|
|
315
|
+
let prefix = workspacePrefixes.get(membership);
|
|
316
|
+
if (prefix === undefined) {
|
|
317
|
+
prefix = posixRelative(membership.root, membership.realPath);
|
|
318
|
+
workspacePrefixes.set(membership, prefix);
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
key: membership.root,
|
|
322
|
+
path: prefix ? `${prefix}/${normalized}` : normalized,
|
|
323
|
+
};
|
|
324
|
+
};
|
|
@@ -11,14 +11,18 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { Database } from "bun:sqlite";
|
|
14
|
-
// node:fs/promises chmod/mkdir: filesystem structure ops, no Bun equivalent
|
|
15
|
-
import { chmod, mkdir } from "node:fs/promises";
|
|
14
|
+
// node:fs/promises chmod/mkdir/lstat/readdir: filesystem structure ops, no Bun equivalent
|
|
15
|
+
import { chmod, lstat, mkdir, readdir } from "node:fs/promises";
|
|
16
16
|
// node:path has no Bun path utilities
|
|
17
17
|
import { basename, dirname, join } from "node:path";
|
|
18
18
|
|
|
19
19
|
import { MCP_ERRORS } from "./errors";
|
|
20
20
|
import { withWriteLock } from "./file-lock";
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
isOwnerOnlyDescriptor,
|
|
23
|
+
windowsDirectoryDescriptor,
|
|
24
|
+
windowsPrivatePath,
|
|
25
|
+
} from "./windows-private-path";
|
|
22
26
|
import { writeLeasePath } from "./write-lease";
|
|
23
27
|
|
|
24
28
|
/** Committed receipts keep their full outcome this long, then become tombstones. */
|
|
@@ -221,25 +225,75 @@ CREATE TABLE IF NOT EXISTS request_receipts (
|
|
|
221
225
|
/** Ledger directories whose owner-only Windows DACL this process verified. */
|
|
222
226
|
const privateLedgerDirs = new Set<string>();
|
|
223
227
|
|
|
228
|
+
/** Inside the ledger directory: what the last authoritative check verified. */
|
|
229
|
+
const LEDGER_DIR_MARKER = ".owner-only-verified";
|
|
230
|
+
|
|
231
|
+
export interface PrivateDirAcl {
|
|
232
|
+
/** Authoritative check (spawns PowerShell); `create` sets the owner-only DACL first. */
|
|
233
|
+
verify: (dir: string, create: boolean) => Promise<void>;
|
|
234
|
+
/** In-process owner and DACL bytes, or null when they cannot be read. */
|
|
235
|
+
descriptor: (dir: string) => Uint8Array | null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const WINDOWS_ACL: PrivateDirAcl = {
|
|
239
|
+
verify: windowsPrivatePath,
|
|
240
|
+
descriptor: windowsDirectoryDescriptor,
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
/** Directory identity plus descriptor digest; null unless owner-only. */
|
|
244
|
+
async function ledgerDirStamp(
|
|
245
|
+
dir: string,
|
|
246
|
+
acl: PrivateDirAcl
|
|
247
|
+
): Promise<string | null> {
|
|
248
|
+
const { dev, ino } = await lstat(dir, { bigint: true });
|
|
249
|
+
const descriptor = acl.descriptor(dir);
|
|
250
|
+
if (!descriptor || !isOwnerOnlyDescriptor(descriptor)) return null;
|
|
251
|
+
const digest = new Bun.CryptoHasher("sha256")
|
|
252
|
+
.update(descriptor)
|
|
253
|
+
.digest("hex");
|
|
254
|
+
return `${dev}:${ino}:${digest}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
224
257
|
/**
|
|
225
258
|
* Windows ignores POSIX modes: give a new ledger directory the current-user
|
|
226
259
|
* DACL before SQLite creates the database or its WAL/SHM (they inherit it),
|
|
227
260
|
* and refuse an existing one that grants another principal access.
|
|
261
|
+
*
|
|
262
|
+
* The PowerShell check costs a process start, so its success is recorded in a
|
|
263
|
+
* marker inside the directory. A later open skips it only when the directory
|
|
264
|
+
* is the same object and its owner and DACL are byte-identical to the verified
|
|
265
|
+
* ones and still owner-only; reading the marker at all requires access that
|
|
266
|
+
* owner-only DACL grants. Anything else re-runs the authoritative check.
|
|
267
|
+
*
|
|
268
|
+
* An existing but empty directory is secured like a new one: a first open
|
|
269
|
+
* interrupted before its DACL was set leaves exactly that, and nothing inside
|
|
270
|
+
* it could have been exposed.
|
|
228
271
|
*/
|
|
229
|
-
async function
|
|
272
|
+
export async function securePrivateLedgerDir(
|
|
230
273
|
dir: string,
|
|
231
|
-
created:
|
|
274
|
+
created: boolean,
|
|
275
|
+
acl: PrivateDirAcl = WINDOWS_ACL
|
|
232
276
|
): Promise<void> {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
277
|
+
const marker = Bun.file(join(dir, LEDGER_DIR_MARKER));
|
|
278
|
+
if (!created) {
|
|
279
|
+
const stamp = await ledgerDirStamp(dir, acl);
|
|
280
|
+
if (stamp !== null && stamp === (await marker.text().catch(() => null)))
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const secure = created || (await readdir(dir)).length === 0;
|
|
284
|
+
await acl.verify(dir, secure);
|
|
285
|
+
const stamp = await ledgerDirStamp(dir, acl);
|
|
286
|
+
if (stamp !== null) await Bun.write(marker, stamp);
|
|
236
287
|
}
|
|
237
288
|
|
|
238
289
|
async function openLedger(path: string): Promise<Database> {
|
|
239
290
|
try {
|
|
240
291
|
const dir = dirname(path);
|
|
241
292
|
const created = await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
242
|
-
|
|
293
|
+
if (process.platform === "win32" && !privateLedgerDirs.has(dir)) {
|
|
294
|
+
await securePrivateLedgerDir(dir, created !== undefined);
|
|
295
|
+
privateLedgerDirs.add(dir);
|
|
296
|
+
}
|
|
243
297
|
const db = new Database(path, { create: true, strict: true });
|
|
244
298
|
try {
|
|
245
299
|
// POSIX: private before any journal file exists (they inherit this mode).
|
|
@@ -303,6 +303,12 @@ export const runRetrievalReplayCandidate = async (
|
|
|
303
303
|
retrievalScope
|
|
304
304
|
);
|
|
305
305
|
options.limit = scope.value.fetchLimit;
|
|
306
|
+
const scopedCollections = scope.value.collections.filter(
|
|
307
|
+
(name): name is string => name !== undefined
|
|
308
|
+
);
|
|
309
|
+
if (scopedCollections.length > 0) {
|
|
310
|
+
options.graphCollections = scopedCollections;
|
|
311
|
+
}
|
|
306
312
|
const result = await runCandidateOnce(deps, source, candidate, options);
|
|
307
313
|
if (!result.ok) return result;
|
|
308
314
|
outputs.push(result.value);
|
|
@@ -8,6 +8,7 @@ import type { RetrievalTraceTerminalStatus } from "../store/types";
|
|
|
8
8
|
|
|
9
9
|
import { canonicalTraceJson } from "../store/retrieval-trace-codec";
|
|
10
10
|
import { err, ok } from "../store/types";
|
|
11
|
+
import { linkResolutionFingerprintInput } from "./link-workspace";
|
|
11
12
|
import { RetrievalTraceSession } from "./retrieval-trace-session";
|
|
12
13
|
|
|
13
14
|
export const retrievalTraceFailureStatus = (
|
|
@@ -45,6 +46,7 @@ export const buildRetrievalTraceFingerprints = async (input: {
|
|
|
45
46
|
}): Promise<RetrievalTraceFingerprints> => {
|
|
46
47
|
const collections = await input.store.getCollections();
|
|
47
48
|
if (!collections.ok) throw new Error(collections.error.message);
|
|
49
|
+
const linkResolution = linkResolutionFingerprintInput(collections.value);
|
|
48
50
|
const snapshots = [];
|
|
49
51
|
for (const collection of [...collections.value].sort((left, right) =>
|
|
50
52
|
left.name.localeCompare(right.name)
|
|
@@ -70,6 +72,7 @@ export const buildRetrievalTraceFingerprints = async (input: {
|
|
|
70
72
|
index: fingerprint({
|
|
71
73
|
indexName: input.indexName ?? "default",
|
|
72
74
|
snapshots,
|
|
75
|
+
...(linkResolution ? { linkResolution } : {}),
|
|
73
76
|
}),
|
|
74
77
|
};
|
|
75
78
|
};
|