@gmickel/gno 1.34.6 → 1.36.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 +12 -1
- package/assets/skill/SKILL.md +26 -3
- package/assets/skill/cli-reference.md +9 -0
- package/assets/skill/mcp-reference.md +3 -2
- package/browser-extension/artifacts/{gno-browser-clipper-v1.34.6.zip → gno-browser-clipper-v1.36.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.36.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +1 -1
- package/spec/cli.md +111 -1
- package/spec/mcp.md +60 -1
- package/spec/output-schemas/mcp-job-status.schema.json +6 -2
- package/spec/output-schemas/peek.schema.json +212 -0
- package/spec/output-schemas/search-results.schema.json +1 -1
- package/src/cli/commands/peek.ts +66 -0
- package/src/cli/options.ts +2 -0
- package/src/cli/program.ts +20 -0
- package/src/config/index.ts +4 -0
- package/src/config/types.ts +14 -0
- package/src/core/path-rules.ts +34 -0
- package/src/core/peek.ts +202 -0
- package/src/ingestion/index.ts +21 -0
- package/src/ingestion/record-container.ts +23 -1
- package/src/ingestion/source-availability/darwin-io.ts +295 -0
- package/src/ingestion/source-availability/darwin-path.ts +58 -0
- package/src/ingestion/source-availability/directory.ts +402 -0
- package/src/ingestion/source-availability/index.ts +74 -0
- package/src/ingestion/source-availability/readers.ts +360 -0
- package/src/ingestion/source-availability/resolve.ts +28 -0
- package/src/ingestion/source-availability/types.ts +170 -0
- package/src/ingestion/sync.ts +197 -24
- package/src/ingestion/types.ts +45 -3
- package/src/ingestion/walker.ts +263 -5
- package/src/mcp/http-egress.ts +1 -0
- package/src/mcp/tools/index.ts +14 -0
- package/src/mcp/tools/peek.ts +78 -0
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/watch-reconciliation-fallback-disk.ts +239 -100
- package/src/serve/watch-reconciliation-fallback.ts +35 -5
- package/src/serve/watch-reconciliation-shared.ts +8 -3
- package/src/serve/watch-reconciliation.ts +7 -0
- package/src/serve/watch-service-flush.ts +10 -0
- package/src/serve/watch-service-lifecycle.ts +2 -0
- package/src/serve/watch-service-snapshot.ts +27 -3
- package/src/serve/watch-service.ts +1 -0
- package/src/serve/watch-snapshot-availability.ts +51 -0
- package/src/serve/watch-snapshot-handles.ts +117 -37
- package/src/serve/watch-snapshot-libc.ts +141 -22
- package/src/serve/watch-snapshot-ops.ts +151 -9
- package/src/serve/watch-snapshot-scan.ts +3 -0
- package/src/serve/watch-snapshot-types.ts +45 -3
- package/browser-extension/artifacts/gno-browser-clipper-v1.34.6.zip.sha256 +0 -1
package/src/cli/options.ts
CHANGED
|
@@ -68,6 +68,7 @@ export const CMD = {
|
|
|
68
68
|
multiGet: "multi-get",
|
|
69
69
|
ls: "ls",
|
|
70
70
|
status: "status",
|
|
71
|
+
peek: "peek",
|
|
71
72
|
publishExport: "publish.export",
|
|
72
73
|
collectionList: "collection.list",
|
|
73
74
|
contextList: "context.list",
|
|
@@ -101,6 +102,7 @@ const FORMAT_SUPPORT: Record<CommandId, OutputFormat[]> = {
|
|
|
101
102
|
[CMD.multiGet]: ["terminal", "json", "files", "md"],
|
|
102
103
|
[CMD.ls]: ["terminal", "json", "files", "md"],
|
|
103
104
|
[CMD.status]: ["terminal", "json"],
|
|
105
|
+
[CMD.peek]: ["terminal", "json"],
|
|
104
106
|
[CMD.publishExport]: ["terminal", "json"],
|
|
105
107
|
[CMD.collectionList]: ["terminal", "json", "md"],
|
|
106
108
|
[CMD.contextList]: ["terminal", "json", "md"],
|
package/src/cli/program.ts
CHANGED
|
@@ -1676,6 +1676,26 @@ function wireOnboardingCommands(program: Command): void {
|
|
|
1676
1676
|
);
|
|
1677
1677
|
});
|
|
1678
1678
|
|
|
1679
|
+
// peek - Cheap metadata snapshot for integrations
|
|
1680
|
+
program
|
|
1681
|
+
.command("peek")
|
|
1682
|
+
.description("Show a cheap index snapshot for integrations")
|
|
1683
|
+
.option("--json", "JSON output")
|
|
1684
|
+
.action(async (cmdOpts: Record<string, unknown>) => {
|
|
1685
|
+
const format = getFormat(cmdOpts);
|
|
1686
|
+
assertFormatSupported(CMD.peek, format);
|
|
1687
|
+
|
|
1688
|
+
const { peek, formatPeek } = await import("./commands/peek");
|
|
1689
|
+
const globals = getGlobals();
|
|
1690
|
+
const snapshot = await peek({
|
|
1691
|
+
configPath: globals.config,
|
|
1692
|
+
indexName: globals.index,
|
|
1693
|
+
});
|
|
1694
|
+
process.stdout.write(
|
|
1695
|
+
`${formatPeek(snapshot, { json: format === "json" })}\n`
|
|
1696
|
+
);
|
|
1697
|
+
});
|
|
1698
|
+
|
|
1679
1699
|
// doctor - Diagnose configuration issues
|
|
1680
1700
|
program
|
|
1681
1701
|
.command("doctor")
|
package/src/config/index.ts
CHANGED
|
@@ -60,6 +60,7 @@ export {
|
|
|
60
60
|
type Config,
|
|
61
61
|
ConfigSchema,
|
|
62
62
|
DEFAULT_EGRESS_POLICY,
|
|
63
|
+
DEFAULT_SOURCE_AVAILABILITY,
|
|
63
64
|
HttpGatewayConfigSchema,
|
|
64
65
|
HttpGatewayLimitsSchema,
|
|
65
66
|
CONTENT_TYPE_GRAPH_HINTS,
|
|
@@ -84,6 +85,9 @@ export {
|
|
|
84
85
|
isValidLanguageHint,
|
|
85
86
|
parseScope,
|
|
86
87
|
resolveConfiguredEgressPolicy,
|
|
88
|
+
SOURCE_AVAILABILITY_MODES,
|
|
89
|
+
type SourceAvailabilityMode,
|
|
90
|
+
SourceAvailabilitySchema,
|
|
87
91
|
type ProjectProfileBinding,
|
|
88
92
|
ProjectProfileBindingSchema,
|
|
89
93
|
type ScopeType,
|
package/src/config/types.ts
CHANGED
|
@@ -56,6 +56,12 @@ export type EgressPolicy = z.infer<typeof EgressPolicySchema>;
|
|
|
56
56
|
/** Missing policy is always interpreted as the fail-closed local-only default. */
|
|
57
57
|
export const DEFAULT_EGRESS_POLICY: EgressPolicy = "local_only";
|
|
58
58
|
|
|
59
|
+
/** Source byte materialization boundary for collection indexing. */
|
|
60
|
+
export const SOURCE_AVAILABILITY_MODES = ["any", "local"] as const;
|
|
61
|
+
export const SourceAvailabilitySchema = z.enum(SOURCE_AVAILABILITY_MODES);
|
|
62
|
+
export type SourceAvailabilityMode = z.infer<typeof SourceAvailabilitySchema>;
|
|
63
|
+
export const DEFAULT_SOURCE_AVAILABILITY: SourceAvailabilityMode = "any";
|
|
64
|
+
|
|
59
65
|
/** Provenance for an effective collection egress policy. */
|
|
60
66
|
export const EGRESS_POLICY_SOURCES = [
|
|
61
67
|
"explicit",
|
|
@@ -138,6 +144,14 @@ export const CollectionSchema = z.object({
|
|
|
138
144
|
*/
|
|
139
145
|
egressPolicyRevision: z.number().int().nonnegative().optional(),
|
|
140
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Source content availability policy for indexing. Distinct from egress:
|
|
149
|
+
* controls whether cloud placeholders may be materialized, not where data
|
|
150
|
+
* may leave the machine. Omitted / unset means `any` (legacy read behavior);
|
|
151
|
+
* `local` is opt-in and fails closed where the platform guard is unavailable.
|
|
152
|
+
*/
|
|
153
|
+
sourceAvailability: SourceAvailabilitySchema.optional(),
|
|
154
|
+
|
|
141
155
|
/** Optional per-collection model overrides */
|
|
142
156
|
models: z
|
|
143
157
|
.object({
|
package/src/core/path-rules.ts
CHANGED
|
@@ -51,3 +51,37 @@ export function matchesCollectionExclusion(
|
|
|
51
51
|
}
|
|
52
52
|
return false;
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Whether a known directory is the root of a fully excluded subtree.
|
|
57
|
+
* A trailing `/**` excludes every descendant even though Bun.Glob does not
|
|
58
|
+
* match the directory root itself. Other partial glob shapes must still be
|
|
59
|
+
* traversed because they may leave eligible descendants.
|
|
60
|
+
*/
|
|
61
|
+
export function matchesCollectionSubtreeExclusion(
|
|
62
|
+
relPath: string,
|
|
63
|
+
excludes: readonly string[]
|
|
64
|
+
): boolean {
|
|
65
|
+
const normalizedPath = relPath.replaceAll("\\", "/");
|
|
66
|
+
if (matchesCollectionExclusion(normalizedPath, excludes)) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const rawPattern of excludes) {
|
|
71
|
+
const pattern = rawPattern.replaceAll("\\", "/");
|
|
72
|
+
if (pattern === "**") {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
if (!pattern.endsWith("/**")) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const subtreeRootPattern = pattern.slice(0, -3);
|
|
79
|
+
if (
|
|
80
|
+
subtreeRootPattern.length > 0 &&
|
|
81
|
+
new Bun.Glob(subtreeRootPattern).match(normalizedPath)
|
|
82
|
+
) {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
package/src/core/peek.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared peek snapshot builder for CLI and later MCP.
|
|
3
|
+
* Metadata-only: never opens the model cache, resolves model URIs, or activates.
|
|
4
|
+
*
|
|
5
|
+
* @module src/core/peek
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// node:path — no Bun path utils
|
|
9
|
+
import { join as pathJoin } from "node:path";
|
|
10
|
+
|
|
11
|
+
import type { DocumentRow, IndexStatus } from "../store/types";
|
|
12
|
+
|
|
13
|
+
import { DEFAULT_INDEX_NAME, VERSION, getIndexDbPath } from "../app/constants";
|
|
14
|
+
import {
|
|
15
|
+
isProcessAlive,
|
|
16
|
+
readPidFile,
|
|
17
|
+
resolveProcessPaths,
|
|
18
|
+
} from "../cli/detach";
|
|
19
|
+
import { CliError } from "../cli/errors";
|
|
20
|
+
import { isInitialized, loadConfig } from "../config";
|
|
21
|
+
import { SqliteAdapter } from "../store/sqlite/adapter";
|
|
22
|
+
|
|
23
|
+
export const PEEK_SCHEMA_VERSION = "peek@1.0" as const;
|
|
24
|
+
export const PEEK_RECENT_LIMIT = 10;
|
|
25
|
+
|
|
26
|
+
export interface PeekCounts {
|
|
27
|
+
documents: number;
|
|
28
|
+
collections: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PeekBacklog {
|
|
32
|
+
pending: number;
|
|
33
|
+
failed: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PeekRecentItem {
|
|
37
|
+
docid: string;
|
|
38
|
+
uri: string;
|
|
39
|
+
title: string | null;
|
|
40
|
+
collection: string;
|
|
41
|
+
absPath: string;
|
|
42
|
+
modifiedAt: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface PeekServe {
|
|
46
|
+
running: boolean;
|
|
47
|
+
url: string | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface PeekSnapshot {
|
|
51
|
+
schemaVersion: typeof PEEK_SCHEMA_VERSION;
|
|
52
|
+
gnoVersion: string;
|
|
53
|
+
generatedAt: string;
|
|
54
|
+
initialized: boolean;
|
|
55
|
+
indexName: string;
|
|
56
|
+
counts: PeekCounts | null;
|
|
57
|
+
backlog: PeekBacklog | null;
|
|
58
|
+
lastIndexedAt: string | null;
|
|
59
|
+
recent: PeekRecentItem[];
|
|
60
|
+
serve: PeekServe;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface BuildPeekOptions {
|
|
64
|
+
configPath?: string;
|
|
65
|
+
indexName?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function asRuntimeError(error: unknown, fallback: string): CliError {
|
|
69
|
+
if (error instanceof CliError) {
|
|
70
|
+
return error;
|
|
71
|
+
}
|
|
72
|
+
return new CliError(
|
|
73
|
+
"RUNTIME",
|
|
74
|
+
error instanceof Error ? error.message : fallback
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function readServeLiveness(): Promise<PeekServe> {
|
|
79
|
+
try {
|
|
80
|
+
const { pidFile } = resolveProcessPaths("serve");
|
|
81
|
+
const payload = await readPidFile(pidFile);
|
|
82
|
+
if (!payload || !isProcessAlive(payload.pid) || payload.port == null) {
|
|
83
|
+
return { running: false, url: null };
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
running: true,
|
|
87
|
+
url: `http://localhost:${payload.port}`,
|
|
88
|
+
};
|
|
89
|
+
} catch (error) {
|
|
90
|
+
throw asRuntimeError(error, "Failed to read serve process state");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function mapRecent(
|
|
95
|
+
documents: DocumentRow[],
|
|
96
|
+
status: IndexStatus
|
|
97
|
+
): PeekRecentItem[] {
|
|
98
|
+
const collectionPaths = new Map(
|
|
99
|
+
status.collections.map((collection) => [collection.name, collection.path])
|
|
100
|
+
);
|
|
101
|
+
return documents.slice(0, PEEK_RECENT_LIMIT).map((doc) => {
|
|
102
|
+
const sourceRelPath = doc.recordSourcePath ?? doc.relPath;
|
|
103
|
+
const collectionPath = collectionPaths.get(doc.collection) ?? "";
|
|
104
|
+
return {
|
|
105
|
+
docid: doc.docid,
|
|
106
|
+
uri: doc.uri,
|
|
107
|
+
title: doc.title,
|
|
108
|
+
collection: doc.collection,
|
|
109
|
+
absPath: pathJoin(collectionPath, sourceRelPath),
|
|
110
|
+
modifiedAt: doc.sourceMtime,
|
|
111
|
+
};
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function emptySnapshot(
|
|
116
|
+
indexName: string,
|
|
117
|
+
generatedAt: string,
|
|
118
|
+
serve: PeekServe
|
|
119
|
+
): PeekSnapshot {
|
|
120
|
+
return {
|
|
121
|
+
schemaVersion: PEEK_SCHEMA_VERSION,
|
|
122
|
+
gnoVersion: VERSION,
|
|
123
|
+
generatedAt,
|
|
124
|
+
initialized: false,
|
|
125
|
+
indexName,
|
|
126
|
+
counts: null,
|
|
127
|
+
backlog: null,
|
|
128
|
+
lastIndexedAt: null,
|
|
129
|
+
recent: [],
|
|
130
|
+
serve,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Build a peek@1.0 snapshot. Throws CliError("RUNTIME") on any subquery
|
|
136
|
+
* failure so callers never emit a half-filled payload.
|
|
137
|
+
*/
|
|
138
|
+
export async function buildPeekSnapshot(
|
|
139
|
+
options: BuildPeekOptions = {}
|
|
140
|
+
): Promise<PeekSnapshot> {
|
|
141
|
+
const generatedAt = new Date().toISOString();
|
|
142
|
+
const requestedIndex = options.indexName ?? DEFAULT_INDEX_NAME;
|
|
143
|
+
const serve = await readServeLiveness();
|
|
144
|
+
|
|
145
|
+
const initialized = await isInitialized(options.configPath);
|
|
146
|
+
if (!initialized) {
|
|
147
|
+
return emptySnapshot(requestedIndex, generatedAt, serve);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const configResult = await loadConfig(options.configPath);
|
|
151
|
+
if (!configResult.ok) {
|
|
152
|
+
throw new CliError("RUNTIME", configResult.error.message);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const store = new SqliteAdapter();
|
|
156
|
+
const openResult = await store.open(
|
|
157
|
+
getIndexDbPath(options.indexName),
|
|
158
|
+
configResult.value.ftsTokenizer
|
|
159
|
+
);
|
|
160
|
+
if (!openResult.ok) {
|
|
161
|
+
throw new CliError("RUNTIME", openResult.error.message);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const statusResult = await store.getStatus();
|
|
166
|
+
if (!statusResult.ok) {
|
|
167
|
+
throw new CliError("RUNTIME", statusResult.error.message);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const recentResult = await store.listDocumentsPaginated({
|
|
171
|
+
limit: PEEK_RECENT_LIMIT,
|
|
172
|
+
offset: 0,
|
|
173
|
+
});
|
|
174
|
+
if (!recentResult.ok) {
|
|
175
|
+
throw new CliError("RUNTIME", recentResult.error.message);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const status = statusResult.value;
|
|
179
|
+
return {
|
|
180
|
+
schemaVersion: PEEK_SCHEMA_VERSION,
|
|
181
|
+
gnoVersion: VERSION,
|
|
182
|
+
generatedAt,
|
|
183
|
+
initialized: true,
|
|
184
|
+
indexName: status.indexName,
|
|
185
|
+
counts: {
|
|
186
|
+
documents: status.activeDocuments,
|
|
187
|
+
collections: status.collections.length,
|
|
188
|
+
},
|
|
189
|
+
backlog: {
|
|
190
|
+
pending: status.embeddingBacklog,
|
|
191
|
+
failed: status.recentErrors,
|
|
192
|
+
},
|
|
193
|
+
lastIndexedAt: status.lastUpdatedAt,
|
|
194
|
+
recent: mapRecent(recentResult.value.documents, status),
|
|
195
|
+
serve,
|
|
196
|
+
};
|
|
197
|
+
} catch (error) {
|
|
198
|
+
throw asRuntimeError(error, "Failed to build peek snapshot");
|
|
199
|
+
} finally {
|
|
200
|
+
await store.close();
|
|
201
|
+
}
|
|
202
|
+
}
|
package/src/ingestion/index.ts
CHANGED
|
@@ -11,6 +11,27 @@ export { defaultLanguageDetector, SimpleLanguageDetector } from "./language";
|
|
|
11
11
|
// Sync service
|
|
12
12
|
export { defaultSyncService, SyncService } from "./sync";
|
|
13
13
|
export { resolveContentTypeRules, withContentTypeRules } from "./sync-options";
|
|
14
|
+
// Source availability (content-boundary guard)
|
|
15
|
+
export {
|
|
16
|
+
createDirectoryAvailability,
|
|
17
|
+
createSourceContentReader,
|
|
18
|
+
DEFAULT_SOURCE_AVAILABILITY,
|
|
19
|
+
findUnprovenAvailabilityPrefix,
|
|
20
|
+
isSourceAvailabilitySkip,
|
|
21
|
+
isUnprovenAbsenceCode,
|
|
22
|
+
memoizeDirectoryAvailability,
|
|
23
|
+
relPathUnderAnyPrefix,
|
|
24
|
+
resolveSourceAvailability,
|
|
25
|
+
SOURCE_AVAILABILITY_MODES,
|
|
26
|
+
} from "./source-availability";
|
|
27
|
+
export type {
|
|
28
|
+
DirectoryAvailabilityPort,
|
|
29
|
+
DirectoryAvailabilityResult,
|
|
30
|
+
SourceAvailabilityCode,
|
|
31
|
+
SourceAvailabilityMode,
|
|
32
|
+
SourceContentReaderPort,
|
|
33
|
+
SourceReadResult,
|
|
34
|
+
} from "./source-availability";
|
|
14
35
|
// Types
|
|
15
36
|
export type {
|
|
16
37
|
ChunkerPort,
|
|
@@ -58,6 +58,11 @@ interface RecordContainerInput {
|
|
|
58
58
|
sourceMtime: string;
|
|
59
59
|
sourceSize: number;
|
|
60
60
|
store: StorePort;
|
|
61
|
+
/**
|
|
62
|
+
* Optional pre-read source bytes from the guarded content boundary.
|
|
63
|
+
* When set, record import streams from this buffer and never reopens the path.
|
|
64
|
+
*/
|
|
65
|
+
sourceBytes?: Uint8Array;
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
interface AppliedRecordReconciliation {
|
|
@@ -283,6 +288,20 @@ const sourceStream = (
|
|
|
283
288
|
},
|
|
284
289
|
});
|
|
285
290
|
|
|
291
|
+
const bytesStream = (
|
|
292
|
+
bytes: Uint8Array,
|
|
293
|
+
signal?: AbortSignal
|
|
294
|
+
): AsyncIterable<Uint8Array> => ({
|
|
295
|
+
async *[Symbol.asyncIterator]() {
|
|
296
|
+
if (signal?.aborted) {
|
|
297
|
+
throw new Error("record adapter aborted");
|
|
298
|
+
}
|
|
299
|
+
if (bytes.byteLength > 0) {
|
|
300
|
+
yield bytes;
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
|
|
286
305
|
const loadPreviousStructure = async (
|
|
287
306
|
store: StorePort,
|
|
288
307
|
existing: DocumentRow | undefined
|
|
@@ -443,7 +462,10 @@ export async function processRecordContainer(
|
|
|
443
462
|
collection: input.collection.name,
|
|
444
463
|
mime: input.mime,
|
|
445
464
|
ext: input.ext,
|
|
446
|
-
open: (signal) =>
|
|
465
|
+
open: (signal) =>
|
|
466
|
+
input.sourceBytes
|
|
467
|
+
? bytesStream(input.sourceBytes, signal)
|
|
468
|
+
: sourceStream(input.entry.absPath, signal),
|
|
447
469
|
limits: {
|
|
448
470
|
...DEFAULT_RECORD_ADAPTER_LIMITS,
|
|
449
471
|
timeoutMs: Math.min(
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Darwin FFI backend for no-materialization I/O policy and guarded content reads.
|
|
3
|
+
* Provider-neutral; reuses the TN3150 mechanism proven in
|
|
4
|
+
* scripts/macos-file-provider-smoke.ts (IOPOL_TYPE_VFS_MATERIALIZE_DATALESS_FILES).
|
|
5
|
+
* No provider SDK; no pin/evict/download/availability mutation.
|
|
6
|
+
*
|
|
7
|
+
* @module src/ingestion/source-availability/darwin-io
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// bun:ffi — getiopolicy_np/setiopolicy_np/open/read require libc FFI; no Bun high-level equivalent
|
|
11
|
+
import { dlopen, FFIType, ptr, toArrayBuffer } from "bun:ffi";
|
|
12
|
+
// node:fs constants expose Darwin O_NOFOLLOW; Bun has no file-open flag API.
|
|
13
|
+
import { constants as fsConstants } from "node:fs";
|
|
14
|
+
|
|
15
|
+
/** TN3150 / File Provider constants (from smoke harness evidence). */
|
|
16
|
+
export const SF_DATALESS = 0x4000_0000;
|
|
17
|
+
export const IOPOL_TYPE_VFS_MATERIALIZE_DATALESS_FILES = 3;
|
|
18
|
+
export const IOPOL_SCOPE_PROCESS = 0;
|
|
19
|
+
export const IOPOL_MATERIALIZE_DATALESS_FILES_OFF = 1;
|
|
20
|
+
/** Darwin errno for guarded dataless materialization refusal. */
|
|
21
|
+
export const DARWIN_EDEADLK = 11;
|
|
22
|
+
export const DARWIN_EACCES = 13;
|
|
23
|
+
export const DARWIN_EPERM = 1;
|
|
24
|
+
export const DARWIN_ENOENT = 2;
|
|
25
|
+
export const DARWIN_EISDIR = 21;
|
|
26
|
+
export const DARWIN_ELOOP = 62;
|
|
27
|
+
export const DARWIN_EIO = 5;
|
|
28
|
+
const OPEN_RDONLY_NOFOLLOW = fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW;
|
|
29
|
+
|
|
30
|
+
export type DarwinIoPolicyPort = {
|
|
31
|
+
get: (type: number, scope: number) => number;
|
|
32
|
+
set: (type: number, scope: number, policy: number) => number;
|
|
33
|
+
readErrno: () => number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type DarwinFileIoPort = {
|
|
37
|
+
open: (absPath: string, flags: number) => number;
|
|
38
|
+
read: (fd: number, buf: Uint8Array) => number;
|
|
39
|
+
close: (fd: number) => number;
|
|
40
|
+
readErrno: () => number;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* No-follow lstat of `st_flags` for SF_DATALESS directory classification.
|
|
45
|
+
* Distinct from content open/read — used only at directory boundaries.
|
|
46
|
+
*/
|
|
47
|
+
export type DarwinStatPort = {
|
|
48
|
+
lstatFlags: (
|
|
49
|
+
absPath: string
|
|
50
|
+
) => { ok: true; stFlags: number } | { ok: false; errno: number };
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type DarwinIoBundle = {
|
|
54
|
+
policy: DarwinIoPolicyPort;
|
|
55
|
+
file: DarwinFileIoPort;
|
|
56
|
+
stat: DarwinStatPort;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Darwin `struct stat` layout used by the smoke harness (arm64/x86_64). */
|
|
60
|
+
export const DARWIN_STAT_BUF_SIZE = 144;
|
|
61
|
+
export const DARWIN_ST_FLAGS_OFFSET = 116;
|
|
62
|
+
|
|
63
|
+
type LibSymbols = {
|
|
64
|
+
getiopolicy_np: (type: number, scope: number) => number;
|
|
65
|
+
setiopolicy_np: (type: number, scope: number, policy: number) => number;
|
|
66
|
+
open: (path: ReturnType<typeof ptr>, flags: number) => number;
|
|
67
|
+
close: (fd: number) => number;
|
|
68
|
+
read: (fd: number, buf: ReturnType<typeof ptr>, n: bigint) => bigint;
|
|
69
|
+
lstat: (path: ReturnType<typeof ptr>, buf: ReturnType<typeof ptr>) => number;
|
|
70
|
+
__error: () => ReturnType<typeof ptr> | null;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
let cachedBundle: DarwinIoBundle | null | undefined;
|
|
74
|
+
|
|
75
|
+
/** Test-only: clear FFI caches between cases. */
|
|
76
|
+
export function resetDarwinIoCachesForTests(): void {
|
|
77
|
+
cachedBundle = undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function asFn<T extends (...args: never[]) => unknown>(
|
|
81
|
+
value: unknown
|
|
82
|
+
): T | null {
|
|
83
|
+
return typeof value === "function" ? (value as T) : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function cstr(value: string): Uint8Array {
|
|
87
|
+
return Buffer.from(`${value}\0`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function readErrnoFrom(symbols: LibSymbols): number {
|
|
91
|
+
const p = symbols.__error();
|
|
92
|
+
if (!p) {
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
return new Int32Array(toArrayBuffer(p, 0, 4))[0] ?? 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function loadLibSystem(): {
|
|
99
|
+
library: ReturnType<typeof dlopen>;
|
|
100
|
+
symbols: LibSymbols;
|
|
101
|
+
} | null {
|
|
102
|
+
if (process.platform !== "darwin") {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
for (const soname of ["libSystem.B.dylib", "libSystem.dylib"]) {
|
|
106
|
+
try {
|
|
107
|
+
const library = dlopen(soname, {
|
|
108
|
+
getiopolicy_np: {
|
|
109
|
+
args: [FFIType.i32, FFIType.i32],
|
|
110
|
+
returns: FFIType.i32,
|
|
111
|
+
},
|
|
112
|
+
setiopolicy_np: {
|
|
113
|
+
args: [FFIType.i32, FFIType.i32, FFIType.i32],
|
|
114
|
+
returns: FFIType.i32,
|
|
115
|
+
},
|
|
116
|
+
open: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
|
|
117
|
+
close: { args: [FFIType.i32], returns: FFIType.i32 },
|
|
118
|
+
read: {
|
|
119
|
+
args: [FFIType.i32, FFIType.ptr, FFIType.u64],
|
|
120
|
+
returns: FFIType.i64,
|
|
121
|
+
},
|
|
122
|
+
lstat: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
|
|
123
|
+
__error: { args: [], returns: FFIType.ptr },
|
|
124
|
+
});
|
|
125
|
+
const raw = library.symbols as Record<string, unknown>;
|
|
126
|
+
const getiopolicy_np = asFn<LibSymbols["getiopolicy_np"]>(
|
|
127
|
+
raw.getiopolicy_np
|
|
128
|
+
);
|
|
129
|
+
const setiopolicy_np = asFn<LibSymbols["setiopolicy_np"]>(
|
|
130
|
+
raw.setiopolicy_np
|
|
131
|
+
);
|
|
132
|
+
const openFn = asFn<LibSymbols["open"]>(raw.open);
|
|
133
|
+
const closeFn = asFn<LibSymbols["close"]>(raw.close);
|
|
134
|
+
const readFn = asFn<LibSymbols["read"]>(raw.read);
|
|
135
|
+
const lstatFn = asFn<LibSymbols["lstat"]>(raw.lstat);
|
|
136
|
+
const errorFn = asFn<LibSymbols["__error"]>(raw.__error);
|
|
137
|
+
if (
|
|
138
|
+
!getiopolicy_np ||
|
|
139
|
+
!setiopolicy_np ||
|
|
140
|
+
!openFn ||
|
|
141
|
+
!closeFn ||
|
|
142
|
+
!readFn ||
|
|
143
|
+
!lstatFn ||
|
|
144
|
+
!errorFn
|
|
145
|
+
) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
library,
|
|
150
|
+
symbols: {
|
|
151
|
+
getiopolicy_np,
|
|
152
|
+
setiopolicy_np,
|
|
153
|
+
open: openFn,
|
|
154
|
+
close: closeFn,
|
|
155
|
+
read: readFn,
|
|
156
|
+
lstat: lstatFn,
|
|
157
|
+
__error: errorFn,
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
} catch {
|
|
161
|
+
// try next soname
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Load Darwin policy + file I/O ports; null when unavailable. */
|
|
168
|
+
export function loadDarwinIo(): DarwinIoBundle | null {
|
|
169
|
+
if (cachedBundle !== undefined) {
|
|
170
|
+
return cachedBundle;
|
|
171
|
+
}
|
|
172
|
+
const loaded = loadLibSystem();
|
|
173
|
+
if (!loaded) {
|
|
174
|
+
cachedBundle = null;
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
const { symbols } = loaded;
|
|
178
|
+
const readErrno = (): number => readErrnoFrom(symbols);
|
|
179
|
+
cachedBundle = {
|
|
180
|
+
policy: {
|
|
181
|
+
get: symbols.getiopolicy_np,
|
|
182
|
+
set: symbols.setiopolicy_np,
|
|
183
|
+
readErrno,
|
|
184
|
+
},
|
|
185
|
+
file: {
|
|
186
|
+
open: (absPath: string, flags: number): number =>
|
|
187
|
+
symbols.open(ptr(cstr(absPath)), flags),
|
|
188
|
+
read: (fd: number, buf: Uint8Array): number =>
|
|
189
|
+
Number(symbols.read(fd, ptr(buf), BigInt(buf.byteLength))),
|
|
190
|
+
close: symbols.close,
|
|
191
|
+
readErrno,
|
|
192
|
+
},
|
|
193
|
+
stat: {
|
|
194
|
+
lstatFlags: (absPath: string) => {
|
|
195
|
+
const buf = new Uint8Array(DARWIN_STAT_BUF_SIZE);
|
|
196
|
+
const rc = symbols.lstat(ptr(cstr(absPath)), ptr(buf));
|
|
197
|
+
if (rc !== 0) {
|
|
198
|
+
return { ok: false as const, errno: readErrno() };
|
|
199
|
+
}
|
|
200
|
+
const stFlags = new DataView(
|
|
201
|
+
buf.buffer,
|
|
202
|
+
buf.byteOffset,
|
|
203
|
+
buf.byteLength
|
|
204
|
+
).getUint32(DARWIN_ST_FLAGS_OFFSET, true);
|
|
205
|
+
return { ok: true as const, stFlags };
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
// Keep library strongly referenced via symbols closure for process lifetime.
|
|
210
|
+
void loaded.library;
|
|
211
|
+
return cachedBundle;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Run `fn` under process-scoped IOPOL_MATERIALIZE_DATALESS_FILES_OFF.
|
|
216
|
+
* Always restores prior policy. Fail-closed on setup/restore failure.
|
|
217
|
+
*/
|
|
218
|
+
export function withNoMaterializePolicy<T>(
|
|
219
|
+
run: () => T,
|
|
220
|
+
port: DarwinIoPolicyPort
|
|
221
|
+
):
|
|
222
|
+
| { ok: true; value: T }
|
|
223
|
+
| {
|
|
224
|
+
ok: false;
|
|
225
|
+
error:
|
|
226
|
+
| "policy_get_failed"
|
|
227
|
+
| "policy_set_failed"
|
|
228
|
+
| "policy_restore_failed";
|
|
229
|
+
} {
|
|
230
|
+
let prior: number;
|
|
231
|
+
try {
|
|
232
|
+
prior = port.get(
|
|
233
|
+
IOPOL_TYPE_VFS_MATERIALIZE_DATALESS_FILES,
|
|
234
|
+
IOPOL_SCOPE_PROCESS
|
|
235
|
+
);
|
|
236
|
+
} catch {
|
|
237
|
+
return { ok: false, error: "policy_get_failed" };
|
|
238
|
+
}
|
|
239
|
+
if (prior < 0) {
|
|
240
|
+
return { ok: false, error: "policy_get_failed" };
|
|
241
|
+
}
|
|
242
|
+
let setupResult: number;
|
|
243
|
+
try {
|
|
244
|
+
setupResult = port.set(
|
|
245
|
+
IOPOL_TYPE_VFS_MATERIALIZE_DATALESS_FILES,
|
|
246
|
+
IOPOL_SCOPE_PROCESS,
|
|
247
|
+
IOPOL_MATERIALIZE_DATALESS_FILES_OFF
|
|
248
|
+
);
|
|
249
|
+
} catch {
|
|
250
|
+
return { ok: false, error: "policy_set_failed" };
|
|
251
|
+
}
|
|
252
|
+
if (setupResult !== 0) {
|
|
253
|
+
return { ok: false, error: "policy_set_failed" };
|
|
254
|
+
}
|
|
255
|
+
let value: T;
|
|
256
|
+
let thrown: unknown;
|
|
257
|
+
try {
|
|
258
|
+
value = run();
|
|
259
|
+
} catch (error) {
|
|
260
|
+
thrown = error;
|
|
261
|
+
}
|
|
262
|
+
let restoreResult: number;
|
|
263
|
+
try {
|
|
264
|
+
restoreResult = port.set(
|
|
265
|
+
IOPOL_TYPE_VFS_MATERIALIZE_DATALESS_FILES,
|
|
266
|
+
IOPOL_SCOPE_PROCESS,
|
|
267
|
+
prior
|
|
268
|
+
);
|
|
269
|
+
} catch {
|
|
270
|
+
return { ok: false, error: "policy_restore_failed" };
|
|
271
|
+
}
|
|
272
|
+
if (restoreResult !== 0) {
|
|
273
|
+
return { ok: false, error: "policy_restore_failed" };
|
|
274
|
+
}
|
|
275
|
+
if (thrown !== undefined) {
|
|
276
|
+
throw thrown;
|
|
277
|
+
}
|
|
278
|
+
return { ok: true, value: value! };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function guardedOpenFlags(): number {
|
|
282
|
+
return OPEN_RDONLY_NOFOLLOW;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function classifyGuardedReadErrno(
|
|
286
|
+
errno: number
|
|
287
|
+
): "EDEADLK" | "EACCES" | "EPERM" | "ENOENT" | "EISDIR" | "ELOOP" | "OTHER" {
|
|
288
|
+
if (errno === DARWIN_EDEADLK) return "EDEADLK";
|
|
289
|
+
if (errno === DARWIN_EACCES) return "EACCES";
|
|
290
|
+
if (errno === DARWIN_EPERM) return "EPERM";
|
|
291
|
+
if (errno === DARWIN_ENOENT) return "ENOENT";
|
|
292
|
+
if (errno === DARWIN_EISDIR) return "EISDIR";
|
|
293
|
+
if (errno === DARWIN_ELOOP) return "ELOOP";
|
|
294
|
+
return "OTHER";
|
|
295
|
+
}
|